fix(apiproxy): give a cold transcript read the agent its presenters live in

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.
This commit is contained in:
Yichen Jiang
2026-08-08 15:27:57 +08:00
parent c5ca12eabd
commit 1c80023b12
2 changed files with 59 additions and 5 deletions

View File

@@ -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<Agent | undefined> {
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,