test(client): cover reconnect and question scope paths

This commit is contained in:
imccyu
2026-08-23 16:16:05 +08:00
parent 18cf84d133
commit 3728c0b13e
5 changed files with 39 additions and 7 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
2026-08-18-session-history-and-event-transport.md: 3d2b6737bcff1a4f929fc5b878f76e1be804a69d
2026-08-18-session-history-and-event-transport.zh.md: 95d271256468e05800b67b3b6989d8909be1ac84
2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd
2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49
@@ -258,11 +258,11 @@ Returning waterfalls currently support Agent scope only. The event signature mus
The Host projects only top-level `agent` and `signal` fields from the request: `agent` becomes top-level `agentId` in the frame, `signal` becomes the delivery lifetime, and all remaining fields must be lossless JSON as a whole.
The Client synchronously resolves `agentId` to an existing Agent Context, restores the current delivery signal into the request's direct `signal` field, and invokes Cordis `waterfall()` on the target Context's private key.
The Client synchronously resolves or materializes an Agent Context from `agentId`, restores the current delivery signal into the request's direct `signal` field, and invokes Cordis `waterfall()` on the target Context's private key. Before the first successful Session-list baseline, the Session-backed adapter lets transport materialize a scope; after that baseline, the list lifecycle owns scope liveness.
The system does not scan arbitrarily deep objects, transmit path arrays or placeholders, deep-clone/restore Context and AbortSignal, or wait for a future Agent Context.
When no Client adapter is registered, the Agent Context is absent, or that Context has been disposed, that Client immediately returns `next`. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery.
When no Client adapter is registered, its resolver returns no Context, or resolution throws, that Client immediately returns `next`. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery.
Gateway Host retains `eventId`, the Host continuation, and delivered Client generations for every unfinished waterfall. A new Client generation receives a replay of the same pending event.
@@ -310,7 +310,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
**Project Agent scope through arbitrary object depth.** Recursive Context and AbortSignal scans need path, placeholder, clone, and restore protocols and turn incidental object structure into a wire promise. Top-level `agent` and `signal` cover current waterfalls.
**Wait for a Client Agent Context or adapter before dispatching.** Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can delegate immediately. Returning `next` when the target is absent preserves Cordis waterfall semantics.
**Wait for a Client Agent Context or adapter before dispatching.** Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can synchronously resolve or materialize its target. Returning `next` when the resolver cannot provide a target immediately preserves Cordis waterfall semantics.
**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection.
@@ -209,7 +209,6 @@ export function apply(ctx: Context): void {
const controller = new ConnectionController(api, source, {
...sinks,
onConnected: (next) => {
if (!ownsGeneration()) return
publishDescription(next)
// A description subscriber may synchronously stop the loop. In that
// case publishDescription(undefined) has already retracted this
@@ -219,7 +218,6 @@ export function apply(ctx: Context): void {
sinks.onConnected?.(next)
},
onStateChange: (state) => {
if (!ownsGeneration()) return
if (state === 'reconnecting') publishDescription(undefined)
if (!ownsGeneration()) return
sinks.onStateChange?.(state)
@@ -213,6 +213,39 @@ describe('connection client apply', () => {
}
})
it('does not announce reconnecting after a description subscriber stops the loop', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const generation = installGeneration(handle)
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
let stoppedOnRetraction = false
const stopDescription = handle.hostDescription.subscribe(() => {
if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return
stoppedOnRetraction = true
owner.loop.stop()
})
const states: string[] = []
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const loop = handle.start({
onStateChange: (state) => { states.push(state) },
}, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 })
owner.loop = loop
try {
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
generation.end()
await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) })
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(states).toEqual(['connected'])
} finally {
stopDescription()
loop.stop()
warnSpy.mockRestore()
}
})
it('WebApiClient keeps unary calls on globalThis.fetch', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
@@ -79,6 +79,7 @@ describe('scoped-dispatch invariants', () => {
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
['user-questions/request', [{ agent, questions: [] }, () => Promise.resolve({ answers: [] })]],
]
for (const [event, args] of rows) {