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.
This commit is contained in:
@@ -1329,23 +1329,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
return undefined
|
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(
|
async function historyStateFor(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
includeProjections: boolean,
|
includeProjections: boolean,
|
||||||
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
|
): Promise<{
|
||||||
|
header: SessionHeader
|
||||||
|
events: SessionEvent[]
|
||||||
|
readProjections: () => SessionProjectionsBlock | undefined
|
||||||
|
}> {
|
||||||
const attached = ctx.sessions.get(sessionId)
|
const attached = ctx.sessions.get(sessionId)
|
||||||
if (attached !== undefined) {
|
if (attached !== undefined) {
|
||||||
const events = [...attached.events]
|
return {
|
||||||
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
|
header: attached.header,
|
||||||
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
|
events: [...attached.events],
|
||||||
|
readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const inspected = await inspectServable(sessionId)
|
const inspected = await inspectServable(sessionId)
|
||||||
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
|
||||||
return {
|
return {
|
||||||
header: inspected.meta,
|
header: inspected.meta,
|
||||||
events: inspected.events,
|
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) {
|
async history(request) {
|
||||||
const { sessionId, beforeSeq, maxMessages } = request.payload
|
const { sessionId, beforeSeq, maxMessages } = request.payload
|
||||||
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
|
let state: Awaited<ReturnType<typeof historyStateFor>>
|
||||||
try {
|
try {
|
||||||
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -2019,11 +2035,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
details: {},
|
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, {
|
return ok(request, {
|
||||||
events: page.events,
|
events: page.events,
|
||||||
hasMore: page.hasMore,
|
hasMore: page.hasMore,
|
||||||
...state.projections === undefined ? {} : { projections: state.projections },
|
...projections === undefined ? {} : { projections },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -515,6 +515,19 @@ describe('replacing a composition', () => {
|
|||||||
expect(warnings).toEqual([])
|
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 () => {
|
it('composes an agent that had nothing installed', async () => {
|
||||||
// An agent created without a preset has no binding to re-link, so the
|
// 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
|
// switch is its first bind — exactly a mount — and once bound only the
|
||||||
|
|||||||
Reference in New Issue
Block a user