From 8d7c4380aa22ca5c4300559030a13be3000b4dc7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:33:26 +0800 Subject: [PATCH] fix(apiproxy): ensure the recorded composition before reading the baseline `session.history` built its projection baseline in `historyStateFor` and only then awaited `presenterScopeFor`, which is what ensures the recorded preset's standing mount. The unit table is process-wide while the units themselves are registered by preset rows, so on a first cold read the baseline was serialized before `todos` (and every other preset-owned key) existed, and every later read served a complete one. Until this branch, a web lane happened to mount a preset in `beforeAll` for unrelated reasons, which hid the ordering. The baseline is now a thunk the handler reads after the scope resolves. Also covers the roster-less early return of the unjoined-agent warning, which the per-file coverage gate reported uncovered. --- packages/host/apiproxy/src/api-proxy.ts | 41 ++++++++++++++----- .../preset/agent-presets/tests/mount.spec.ts | 13 ++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ea41122cc9..34abe41f87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1329,23 +1329,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return undefined } - /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ + /** + * Read one transcript cut without acquiring an Agent owner, plus a DEFERRED + * read of the projection baseline. + * + * The baseline is a thunk rather than a value because the unit table is + * process-wide while the units themselves are registered by preset rows: a + * key like `todos` exists only once its preset's standing mount is composed. + * The caller ensures that mount through {@link presenterScopeFor} — which + * needs the header this function returns — so reading the snapshot eagerly + * would serve a first cold read a page missing every preset-owned key, and + * every later read a complete one. + */ async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ + header: SessionHeader + events: SessionEvent[] + readProjections: () => SessionProjectionsBlock | undefined + }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { - const events = [...attached.events] - const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { header: attached.header, events, ...projections === undefined ? {} : { projections } } + return { + header: attached.header, + events: [...attached.events], + readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined, + } } const inspected = await inspectServable(sessionId) - const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { header: inspected.meta, events: inspected.events, - ...projections === undefined ? {} : { projections }, + readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined, } } @@ -2006,7 +2022,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: Awaited> try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -2019,11 +2035,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) + // The scope resolves first: ensuring the recorded composition's + // standing mount is what registers its projection units, so the + // baseline below has to be read after it, not beside it. + const scope = await presenterScopeFor(sessionId, state) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope) + const projections = state.readProjections() return ok(request, { events: page.events, hasMore: page.hasMore, - ...state.projections === undefined ? {} : { projections: state.projections }, + ...projections === undefined ? {} : { projections }, }) }, diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b2f37e206e..9f2760e704 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -515,6 +515,19 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) + it('says nothing when the deployment configures no roster at all', async () => { + // Presets are optional: every surface except the Web bundle keeps its + // model-facing rows in the host plane, so an agent with a chain of one is + // exactly right there and the diagnostic must stay silent. + const rosterless = await harness({ default: 'standard', roots: [] }) + const warnings: string[] = [] + rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn + + await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') }) + + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the