fix: address review round three

- the subagent projection's empty state becomes a serializable null
  sentinel (undefined fields vanish in JSON push frames, leaving stale
  identities in clients); consumers treat null and a missing key alike,
  and cached sentinels defer to the authoritative refold
- the tool catalog generator mounts the projection registry for
  list_agents; the shared unavailable-error copy goes catalog-neutral
- the design note's verification section records the new
  subagent-diagnostic snapshot honestly, the 2026-07-22 note's test
  inventory is rewritten to the current mechanism, and the session-store
  error code is documented
This commit is contained in:
imccyu
2026-08-06 22:28:32 +08:00
parent a25d4331d7
commit 397d70d545
20 changed files with 146 additions and 74 deletions
@@ -364,6 +364,67 @@ describe('SubagentService.listChildren', () => {
})
})
it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => {
const { ctx, parent } = await setup([])
const liveId = SessionId('invalidated-live-child')
const live = ctx.sessions.create(liveId, {
meta: { parentSession: parent.id, origin: 'subagent' },
})
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('was valid'))
expect(ctx.sessionProjections.snapshot(live).values.subagent)
.toEqual({ mode: 'continuable', label: 'was valid' })
// Last-wins: the malformed follow-up resets the identity to the sentinel.
live.append(
'subagent/descriptor',
{ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never,
)
const values = ctx.sessionProjections.snapshot(live).values
expect(values.subagent).toBeNull()
// The sentinel survives a JSON push frame; an undefined field would be
// dropped there and a consumer would keep the stale identity forever.
const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown>
expect('subagent' in wired).toBe(true)
expect(wired['subagent']).toBeNull()
// The listing reads the same null as no value: running → omitted.
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
})
it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => {
const { ctx, parent } = await setup([])
const events = childEvents(descriptorPayload('was valid'))
events.splice(3, 0, {
type: 'subagent/descriptor',
seq: 3,
time: 3,
data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 },
} as SessionEvent)
events[4] = { ...events[4]!, seq: 4 }
const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', {
parentSession: parent.id,
origin: 'subagent',
}, events)
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: invalidated, reason: 'corrupt' },
])
})
it('lets preparation rule when the cache serves the null sentinel', async () => {
const { ctx, parent } = await setup([], { projectionCache: true })
const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('actually valid')))
// A stale cached sentinel must not out-rank the authoritative re-fold.
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
activity: 'inactive', hasChildren: false,
}])
expect(inspect).toHaveBeenCalledTimes(1)
})
it('maps a child rejected by persistence inspection to unavailable', async () => {
const { ctx, parent } = await setup([])
// The surface-eligible user/message lacks its required surfaceOp, so the