From 1c80023b12394e725af43f3a0f94af12121b936f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 15:27:57 +0800 Subject: [PATCH] fix(apiproxy): give a cold transcript read the agent its presenters live in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset registers its tools into the agent's OWN layer, so `session.history` served while no agent exists found no presenter at all and every card degraded to the generic renderer — silently, because a viewless entry is also what a tool with no presenter produces. The web client opens a session by reading its transcript, so this was the ordinary path, not an edge: the write row lost its diff card, and with it the `locations` the produced-files row derives from. The read now resolves the agent through the same deduplicated resume every other session method takes, but only when a roster is composed: a deployment without presets keeps its tools on the host layer, which needs no agent to address, and keeps the storage-only read unchanged. A resolution failure stays a successful read. --- packages/host/apiproxy/src/api-proxy.ts | 30 +++++++++++++--- .../tests/api-proxy-agent-preset.spec.ts | 34 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 45ff48f790..3397657bed 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1127,6 +1127,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return undefined } + /** + * The agent whose layer holds this session's tool presenters. + * + * A preset registers its tools into the agent's OWN layer, so a transcript + * read while no agent exists finds no presenter at all and every card + * degrades to the generic renderer — silently, because a viewless entry is + * also what a tool with no presenter produces. Reading therefore resolves + * the agent, through the same deduplicated resume every other session method + * takes, whenever a roster is composed. + * + * A deployment composing no roster keeps the storage-only read it always + * had: its tools are in the host layer, which needs no agent to address. A + * resolution failure is not a read failure either — the transcript still + * serves, with the generic cards it would have rendered anyway. + * @param sessionId - the transcript being read. + * @returns the agent to resolve presenters against, or undefined for none. + */ + async function presenterAgentFor(sessionId: SessionId): Promise { + const live = ctx.get('agents')?.get(sessionId) + if (live !== undefined || ctx.get('agentPresets') === undefined) return live + const found = await agentFor(sessionId) + return 'error' in found ? undefined : found.agent + } + /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ async function historyStateFor( sessionId: SessionId, @@ -1756,11 +1780,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - // `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may - // serve history from storage with no agent registry composed at all. - // An absent registry means no live agent, which is the same answer a - // present one gives here — presenters fall back to the global layer. - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId)) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterAgentFor(sessionId)) return ok(request, { events: page.events, hasMore: page.hasMore, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 37244f14b3..38dd8a4ad3 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -239,3 +239,37 @@ describe('a capability the session\'s preset mounts', () => { expect(failure.error.message).toContain('neither this session') }) }) + +describe('session.history presenters', () => { + it('resolves the live agent so a preset-composed presenter is reachable', async () => { + const { api, ctx } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' })) + + const response = await api.sessions.history(request({ sessionId: SessionId('h1') })) + + expect(response.result.ok).toBe(true) + expect(ctx.agents.get(SessionId('h1'))).toBeDefined() + }) + + it('serves the transcript when no agent can be resolved for it', async () => { + // The roster is composed, so the read tries; this harness resumes nothing. + // A resolution failure is not a read failure — the transcript still + // serves, with the generic cards a viewless entry renders. + const { api } = await harness(['standard']) + + const response = await api.sessions.history(request({ sessionId: SessionId('h2') })) + + expect(response.result.ok).toBe(false) + const failure = response.result as { ok: false; error: { code: string } } + expect(failure.error.code).toBe('session-not-found') + }) + + it('keeps the storage-only read when the deployment composes no roster', async () => { + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('h3') })) + + const response = await api.sessions.history(request({ sessionId: SessionId('h3') })) + + expect(response.result.ok).toBe(true) + }) +})