diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 56106c410c..b0b466cfa3 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 3d2b6737bc..206d3d13d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -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. diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 9c386a8752..c56bf17c84 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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) diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 5614c20ade..443d398eba 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -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 } = {} + 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() diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 345766b042..96c1488ea2 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -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) {