From 9c726c70639552807cb0e734068e17715254c573 Mon Sep 17 00:00:00 2001 From: hit_aran Date: Fri, 7 Aug 2026 17:49:47 +0800 Subject: [PATCH 1/7] feat(serve): add pollable turn-status endpoints for daemon sessions Add GET /session/:id/turns/current and GET /session/:id/turns/:promptId so external callers can poll a turn's lifecycle state (queued / running / completed / cancelled / error) and result instead of holding the SSE stream for the whole turn lifetime. - Live state comes from the bridge's pending prompt queue; settled outcomes from persisted turn_result transcript records, so results survive daemon restarts and the daemon keeps no per-turn memory - Each prompt captures its own recording and settles exactly that one, so overlapping turns (DAEMON-003 deadline overlap) can never misattribute one turn's outcome to another promptId - Enforces the same client authorization as POST /session/:id/prompt Refs #8680 --- docs/design/daemon-turn-status-endpoint.md | 44 ++ docs/developers/qwen-serve-protocol.md | 2 + .../cli/qwen-serve-routes.test.ts | 1 + .../cli/qwen-serve-streaming.test.ts | 108 ++- packages/acp-bridge/src/bridge.test.ts | 733 ++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 253 +++++- packages/acp-bridge/src/bridgeTypes.ts | 27 + packages/acp-bridge/src/status.ts | 2 + .../cli/src/acp-integration/acpAgent.test.ts | 253 ++++++ packages/cli/src/acp-integration/acpAgent.ts | 137 ++++ .../acp-integration/session/Session.test.ts | 575 ++++++++++++++ .../src/acp-integration/session/Session.ts | 373 +++++++-- packages/cli/src/serve/acp-session-bridge.ts | 1 + packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/routes/session.ts | 70 ++ packages/cli/src/serve/server.test.ts | 122 +++ .../serve/server/telemetry-catalog.test.ts | 2 +- packages/cli/src/serve/server/telemetry.ts | 12 + .../src/services/chatRecordingService.test.ts | 95 +++ .../core/src/services/chatRecordingService.ts | 197 ++++- .../core/src/services/sessionService.test.ts | 41 + packages/core/src/services/sessionService.ts | 1 + .../src/utils/conversation-branches.test.ts | 2 + .../core/src/utils/conversation-branches.ts | 1 + packages/core/src/utils/transcript-records.ts | 1 + 25 files changed, 2967 insertions(+), 87 deletions(-) create mode 100644 docs/design/daemon-turn-status-endpoint.md diff --git a/docs/design/daemon-turn-status-endpoint.md b/docs/design/daemon-turn-status-endpoint.md new file mode 100644 index 00000000000..fed7125bf36 --- /dev/null +++ b/docs/design/daemon-turn-status-endpoint.md @@ -0,0 +1,44 @@ +# Daemon turn-status endpoint + +## Goal + +Let clients that do not keep the session SSE stream open poll the state and raw final main answer of an admitted daemon prompt by `promptId`. + +The feature is advertised by the always-on `session_turn_status` capability. It has no setting, flag, or environment variable. + +## Scope and ownership + +The read-only routes are Session-scoped: + +- `GET /session/:id/turns/:promptId` +- `GET /session/:id/turns/current` + +They resolve the live runtime that owns `sessionId`, apply the same client-id authorization as `/prompt`, and never scan another workspace or fall back to the primary runtime. The Session must already be live; polling does not load or resume an offline Session. + +`current` returns the running prompt, otherwise the FIFO queued head, otherwise the newest settled result, otherwise `idle`. The exact route returns `404 prompt_not_found` when the live queue, the bounded bridge overlay, and the bounded active-transcript scan contain no matching result. This does not prove that the prompt never existed. + +## Result semantics + +States are `idle`, `queued`, `running`, `completed`, `cancelled`, and `error`. `queuedAt` is admission time. `startedAt` is present only after actual FIFO dispatch into Session/model execution. `endedAt` is terminal time. + +`resultText` is the raw canonical final main answer: top-level, non-thought text from the last primary-model response block that does not contain a tool call. Text emitted before a tool call is discarded. Tool output, thought text, subagent stream updates, diagnostics, background messages, slash-command output, and future output from a sent sub-session are excluded. Optional message rewriting is downstream presentation and does not change this field. A completed turn can therefore have no `resultText` when the parent model produced no final text. + +`promptText` and `resultText` are limited to 32,768 UTF-16 code units. A truncated result has `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`. Error messages and codes are normalized without invoking unsafe getters and are limited to 4,096 and 256 code units respectively. + +## Live and persisted sources + +The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Pending entries that are removed or already terminal are never projected as queued/running. Polling re-reads the overlay after an awaited child read, including when that read fails, so a concurrent terminal cannot regress to queued or not-found. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`. + +Session is the only transcript writer. A daemon prompt that reaches `Session.prompt()` appends one best-effort `turn_result` system record through `ChatRecordingService`. Recording failure never changes the prompt lifecycle. Reads best-effort flush the recorder and walk at most 10 backward pages of 500 active records, with the existing 4 MiB page and snapshot limits. Invalid cursor, unavailable snapshot, oversized snapshot, and oversized page errors remain structured errors rather than becoming not-found. + +Normal restart lookup therefore requires recording to be enabled, the append to have succeeded, the result to remain on the active branch and within the bounded scan window, and the Session to be loaded live again. Deleting the JSONL, disabling recording, a failed append, or leaving the bounded window removes that guarantee. + +Prompts accepted only by the bridge but never dispatched into Session, including queued removal, queued deadline, close/kill cancellation, or forward failure, are available from the in-process overlay only. Unexpected process crashes and daemon shutdown do not trigger transcript backfill. + +## History operations + +A failed rewind keeps the overlay. A successful rewind clears it; the child reader's active transcript branch then decides which results remain queryable. Forking excludes `turn_result` records so a new Session cannot inherit source prompt identities. + +## Non-goals + +This is not an exactly-once or permanent result store. It adds no strict teardown persistence, close/kill write barrier, crash recovery journal, daemon transcript writer, offline workspace scan, promptId index, rewind coordinate map, or message-rewrite refactor. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6b67f2cdcab..5f9ccd9fe3d 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -499,6 +499,8 @@ Both events live in the per-session SSE replay ring (they carry an `id`) so a cl ## Routes +Clients can feature-detect `session_turn_status` and poll `GET /session/:id/turns/current` or `GET /session/:id/turns/:promptId`. These routes require the live owning Session and never load or scan another workspace. Settled results are best-effort transcript records read from the active branch with a bounded scan; `prompt_not_found` means no result was found in the live queue, 64-entry terminal overlay, or bounded active window. `resultText` is the raw final parent-model answer after the last tool boundary, before optional message rewriting, and may be absent. Results over 32,768 UTF-16 code units include `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`. + ### `GET /health` Liveness probe. Default form returns `200 {"status":"ok"}` if the listener is up — cheap, no bridge access, suitable for high-frequency k8s/Compose liveness probes. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index be731880170..64e13f8abff 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -314,6 +314,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_source_metadata', 'session_side_task', 'session_prompt', + 'session_turn_status', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', 'session_cancel', diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts index 19e26af6d03..2d03126ed67 100644 --- a/integration-tests/cli/qwen-serve-streaming.test.ts +++ b/integration-tests/cli/qwen-serve-streaming.test.ts @@ -48,7 +48,10 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { isPathWithinRoot } from '@qwen-code/qwen-code-core'; +import { + isPathWithinRoot, + TURN_RESULT_TEXT_MAX_CHARS, +} from '@qwen-code/qwen-code-core'; import { DaemonClient, parseSseStream } from '@qwen-code/sdk'; import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk'; import { @@ -194,6 +197,32 @@ beforeAll(async () => { return { content: 'The test Todo remains unfinished.' }; } + if (messages.includes('turn-final-answer-boundary-e2e')) { + const toolCallId = 'call_turn_final_answer_boundary'; + if (!messages.includes(toolCallId)) { + return { + content: 'I will inspect the fixture first. ', + toolCalls: [ + fakeToolCall( + 'read_file', + { + file_path: path.join( + workspaceDir, + 'turn-final-answer-boundary.txt', + ), + }, + toolCallId, + ), + ], + }; + } + return { content: 'The strict final answer is 42.' }; + } + + if (messages.includes('turn-result-truncation-e2e')) { + return { content: 'z'.repeat(TURN_RESULT_TEXT_MAX_CHARS + 100) }; + } + if (pendingWritePath && messages.includes('fan-out') && !hasToolResult) { return { toolCalls: [ @@ -272,6 +301,10 @@ beforeAll(async () => { }), ); workspaceDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-streaming-ws-')); + writeFileSync( + path.join(workspaceDir, 'turn-final-answer-boundary.txt'), + '42', + ); daemon = spawn( process.execPath, [ @@ -389,6 +422,79 @@ async function* sseFrames( yield* parseSseStream(res.body!, opts.signal); } +async function turnStatus( + sessionId: string, + promptId: string, +): Promise> { + const response = await fetch( + `${base}/session/${sessionId}/turns/${promptId}`, + { headers: { Authorization: `Bearer ${TOKEN}` } }, + ); + if (!response.ok) return { status: response.status }; + return (await response.json()) as Record; +} + +describePOSIX('qwen serve — pollable turn results', () => { + it('returns only the final parent answer after a tool boundary', async () => { + const session = await client.createOrAttachSession({ + workspaceCwd: workspaceDir, + sessionScope: 'thread', + }); + await client.setSessionApprovalMode(session.sessionId, 'yolo'); + try { + const accepted = asAccepted( + await client.promptNonBlocking(session.sessionId, { + prompt: [{ type: 'text', text: 'turn-final-answer-boundary-e2e' }], + }), + ); + expect(accepted).toBeDefined(); + if (!accepted) return; + + await expect + .poll(() => turnStatus(session.sessionId, accepted.promptId), { + timeout: 30_000, + }) + .toMatchObject({ + state: 'completed', + stopReason: 'end_turn', + resultText: 'The strict final answer is 42.', + }); + } finally { + await client.closeSession(session.sessionId).catch(() => undefined); + } + }, 60_000); + + it('reports truncation through the stable result code', async () => { + const session = await client.createOrAttachSession({ + workspaceCwd: workspaceDir, + sessionScope: 'thread', + }); + try { + const accepted = asAccepted( + await client.promptNonBlocking(session.sessionId, { + prompt: [{ type: 'text', text: 'turn-result-truncation-e2e' }], + }), + ); + expect(accepted).toBeDefined(); + if (!accepted) return; + + await expect + .poll(() => turnStatus(session.sessionId, accepted.promptId), { + timeout: 30_000, + }) + .toMatchObject({ + state: 'completed', + resultTruncated: true, + resultCode: 'RESULT_TEXT_TRUNCATED', + }); + const status = await turnStatus(session.sessionId, accepted.promptId); + expect(status['resultText']).toHaveLength(TURN_RESULT_TEXT_MAX_CHARS); + } finally { + await client.closeSession(session.sessionId).catch(() => undefined); + } + }, 60_000); +}); + describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => { it('publishes session_died after the qwen --acp child is SIGKILL-ed', async () => { const session = await client.createOrAttachSession({ diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index be0de381cb7..845a078f556 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -93,6 +93,7 @@ import { ShellExecutionService, stableSessionArtifactId, ToolNames, + TURN_RESULT_TEXT_MAX_CHARS, } from '@qwen-code/qwen-code-core'; import { FakeAgent, @@ -11289,6 +11290,738 @@ describe('createAcpSessionBridge', () => { }); }); + describe('getSessionTurnStatus', () => { + it('does not report startedAt before FIFO dispatch begins', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + let admittedStatus: Promise | undefined; + + const prompt = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'dispatch boundary' }], + }, + undefined, + { + promptId: 'prompt-dispatch-boundary', + onPromptAdmitted: () => { + expect(handle.agent.promptCalls).toHaveLength(0); + admittedStatus = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-dispatch-boundary', + ); + }, + }, + ); + + await expect(admittedStatus).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-dispatch-boundary', + }); + await expect(admittedStatus).resolves.not.toHaveProperty('startedAt'); + + await prompt; + await bridge.shutdown(); + }); + + it('reports the running turn from the live pending list', async () => { + let resolveTurn: (() => void) | undefined; + const turnGate = new Promise((r) => { + resolveTurn = r; + }); + const handle = makeChannel({ + promptImpl: async () => { + await turnGate; + return { stopReason: 'end_turn' } as PromptResponse; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const promptPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'live turn' }], + }, + undefined, + { promptId: 'prompt-live' }, + ); + + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1); + }); + + const current = await bridge.getSessionTurnStatus(session.sessionId); + expect(current).toMatchObject({ + sessionId: session.sessionId, + state: 'running', + promptId: 'prompt-live', + promptText: 'live turn', + }); + expect(current?.queuedAt).toBeTypeOf('number'); + expect(current?.startedAt).toBeTypeOf('number'); + + const byId = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-live', + ); + expect(byId).toEqual(current); + + resolveTurn!(); + await promptPromise; + await bridge.shutdown(); + }); + + it('caps live promptText at TURN_RESULT_TEXT_MAX_CHARS', async () => { + let resolveTurn: (() => void) | undefined; + const turnGate = new Promise((r) => { + resolveTurn = r; + }); + const handle = makeChannel({ + promptImpl: async () => { + await turnGate; + return { stopReason: 'end_turn' } as PromptResponse; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const longText = 'x'.repeat(TURN_RESULT_TEXT_MAX_CHARS + 100); + const promptPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: longText }], + }, + undefined, + { promptId: 'prompt-long' }, + ); + + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1); + }); + + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-long', + ); + expect(status?.promptText).toHaveLength(TURN_RESULT_TEXT_MAX_CHARS); + expect(status?.promptTextTruncated).toBe(true); + + resolveTurn!(); + await promptPromise; + await bridge.shutdown(); + }); + + it('rejects a foreign clientId like /prompt does', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await expect( + bridge.getSessionTurnStatus(session.sessionId, { + clientId: 'forged-client', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); + await bridge.shutdown(); + }); + + it('reports queued prompts behind a running turn', async () => { + let resolveFirst: (() => void) | undefined; + const firstGate = new Promise((r) => { + resolveFirst = r; + }); + const handle = makeChannel({ + promptImpl: async (req: PromptRequest) => { + if ((req.prompt[0] as { text?: string }).text === 'blocking') { + await firstGate; + } + return { stopReason: 'end_turn' } as PromptResponse; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const p1 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'blocking' }], + }, + undefined, + { promptId: 'prompt-first' }, + ); + const p2 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'waiting' }], + }, + undefined, + { promptId: 'prompt-second' }, + ); + + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(2); + }); + + // Current = the running turn, not the queued head. + const current = await bridge.getSessionTurnStatus(session.sessionId); + expect(current).toMatchObject({ + state: 'running', + promptId: 'prompt-first', + }); + + const queued = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-second', + ); + expect(queued).toMatchObject({ + state: 'queued', + promptId: 'prompt-second', + promptText: 'waiting', + }); + expect(queued?.startedAt).toBeUndefined(); + + resolveFirst!(); + await p1; + await p2; + await bridge.shutdown(); + }); + + it('falls back to persisted turn_result records once settled', async () => { + const turnResult = { + promptId: 'prompt-done', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'settled prompt', + resultText: 'settled answer', + originatorClientId: 'client-9', + }; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + return { v: 1, sessionId: 'ignored', turnResult }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settled prompt' }], + }, + undefined, + { promptId: 'prompt-done' }, + ); + + const byId = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-done', + ); + expect(byId).toMatchObject({ + sessionId: session.sessionId, + state: 'completed', + promptId: 'prompt-done', + stopReason: 'end_turn', + resultText: 'settled answer', + originatorClientId: 'client-9', + }); + + const current = await bridge.getSessionTurnStatus(session.sessionId); + expect(current).toEqual(byId); + await bridge.shutdown(); + }); + + it('resolves undefined for unknown promptId and idle for empty current', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + return { v: 1, sessionId: 'ignored', turnResult: null }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.getSessionTurnStatus(session.sessionId, undefined, 'missing'), + ).resolves.toBeUndefined(); + + await expect( + bridge.getSessionTurnStatus(session.sessionId), + ).resolves.toEqual({ sessionId: session.sessionId, state: 'idle' }); + await bridge.shutdown(); + }); + + it('prefers live queue state over persisted records', async () => { + let resolveTurn: (() => void) | undefined; + const turnGate = new Promise((r) => { + resolveTurn = r; + }); + const extCalls: string[] = []; + const handle = makeChannel({ + promptImpl: async () => { + await turnGate; + return { stopReason: 'end_turn' } as PromptResponse; + }, + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + extCalls.push(method); + return { v: 1, sessionId: 'ignored', turnResult: null }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const promptPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'live wins' }], + }, + undefined, + { promptId: 'prompt-live' }, + ); + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1); + }); + + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-live', + ); + expect(status?.state).toBe('running'); + expect(extCalls).toHaveLength(0); + + resolveTurn!(); + await promptPromise; + await bridge.shutdown(); + }); + + it('keeps a completed turn pollable before its transcript record appears', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { v: 1, sessionId: 'ignored', turnResult: null } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'answer before persistence' }], + }, + undefined, + { promptId: 'prompt-overlay' }, + ); + + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-overlay', + ), + ).resolves.toMatchObject({ + state: 'completed', + promptId: 'prompt-overlay', + promptText: 'answer before persistence', + }); + await bridge.shutdown(); + }); + + it('observes a terminal published while persisted lookup is pending', async () => { + let resolveLookup!: () => void; + const lookupGate = new Promise((resolve) => { + resolveLookup = resolve; + }); + let lookupStarted = false; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + lookupStarted = true; + await lookupGate; + return { v: 1, sessionId: 'ignored', turnResult: null }; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const status = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-race', + ); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settles during read' }], + }, + undefined, + { promptId: 'prompt-race' }, + ); + resolveLookup(); + + await expect(status).resolves.toMatchObject({ + state: 'completed', + promptId: 'prompt-race', + }); + await bridge.shutdown(); + }); + + it('observes an exact live turn admitted while persisted lookup is pending', async () => { + const lookup = deferred<{ + v: number; + sessionId: string; + turnResult: null; + }>(); + const turn = deferred(); + let lookupStarted = false; + const handle = makeChannel({ + promptImpl: () => turn.promise, + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + lookupStarted = true; + return lookup.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const status = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-live-race', + ); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + const prompt = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'starts during read' }], + }, + undefined, + { promptId: 'prompt-live-race' }, + ); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + lookup.resolve({ v: 1, sessionId: session.sessionId, turnResult: null }); + + await expect(status).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-live-race', + }); + turn.resolve({ stopReason: 'end_turn' }); + await prompt; + await bridge.shutdown(); + }); + + it('uses a terminal published while persisted lookup rejects', async () => { + let rejectLookup!: (error: Error) => void; + const lookupGate = new Promise((_resolve, reject) => { + rejectLookup = reject; + }); + let lookupStarted = false; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + lookupStarted = true; + return lookupGate; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const status = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-race-failure', + ); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settles during failed read' }], + }, + undefined, + { promptId: 'prompt-race-failure' }, + ); + rejectLookup(new Error('transcript unavailable')); + + await expect(status).resolves.toMatchObject({ + state: 'completed', + promptId: 'prompt-race-failure', + }); + await bridge.shutdown(); + }); + + it('observes a current live turn admitted while persisted lookup rejects', async () => { + const lookup = deferred(); + const turn = deferred(); + let lookupStarted = false; + const handle = makeChannel({ + promptImpl: () => turn.promise, + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + lookupStarted = true; + return lookup.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const status = bridge.getSessionTurnStatus(session.sessionId); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + const prompt = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'starts during failed read' }], + }, + undefined, + { promptId: 'prompt-live-current-race' }, + ); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + lookup.reject(new Error('transcript unavailable')); + + await expect(status).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-live-current-race', + }); + turn.resolve({ stopReason: 'end_turn' }); + await prompt; + await bridge.shutdown(); + }); + + it('keeps terminal outcome authoritative while persisted text enriches it', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-enriched', + state: 'error', + error: { message: 'stale child error' }, + endedAt: 1, + resultText: 'final parent answer', + }, + } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'answer me' }], + }, + undefined, + { promptId: 'prompt-enriched' }, + ); + + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-enriched', + ), + ).resolves.toMatchObject({ + state: 'completed', + stopReason: 'end_turn', + resultText: 'final parent answer', + }); + await bridge.shutdown(); + }); + + it('keeps the trusted prompt display projection after persistence', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-display', + state: 'completed', + stopReason: 'end_turn', + endedAt: 1, + promptText: 'hello', + }, + } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [ + { + type: 'text', + text: 'internal channel instructions\n\nhello', + }, + ], + }, + undefined, + { promptId: 'prompt-display', promptDisplayText: 'hello' }, + ); + + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-display', + ), + ).resolves.toMatchObject({ + state: 'completed', + promptText: 'hello', + }); + await bridge.shutdown(); + }); + + it('keeps only the latest 64 in-memory terminal statuses', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { v: 1, sessionId: 'ignored', turnResult: null } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + for (let index = 0; index < 65; index += 1) { + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: `prompt ${index}` }], + }, + undefined, + { promptId: `prompt-${index}` }, + ); + } + + await expect( + bridge.getSessionTurnStatus(session.sessionId, undefined, 'prompt-0'), + ).resolves.toBeUndefined(); + await expect( + bridge.getSessionTurnStatus(session.sessionId, undefined, 'prompt-1'), + ).resolves.toMatchObject({ state: 'completed', promptId: 'prompt-1' }); + await bridge.shutdown(); + }); + + it('clears terminal overlays only after a successful rewind', async () => { + let rewindFails = true; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + return { v: 1, sessionId: 'ignored', turnResult: null }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionRewind) { + if (rewindFails) throw new Error('rewind failed'); + return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] }; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'rewind me' }], + }, + undefined, + { promptId: 'prompt-rewind-overlay' }, + ); + + await expect( + bridge.rewindSession(session.sessionId, { promptId: 'target' }), + ).rejects.toThrow(); + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-rewind-overlay', + ), + ).resolves.toMatchObject({ state: 'completed' }); + + rewindFails = false; + await bridge.rewindSession(session.sessionId, { promptId: 'target' }); + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-rewind-overlay', + ), + ).resolves.toBeUndefined(); + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown sessions', async () => { + const bridge = makeBridge(); + await expect(bridge.getSessionTurnStatus('unknown')).rejects.toThrow( + SessionNotFoundError, + ); + }); + }); + describe('prompt terminal exactly-once (DAEMON-002/003/004/005)', () => { /** All formal turn terminals published for one promptId. */ const terminalsFor = (events: BridgeEvent[], promptId: string) => diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 15593456113..1d695694125 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -25,6 +25,7 @@ import type { import type { ApprovalMode, RebuiltSessionArtifactSnapshot, + TurnResultRecordPayload, } from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, @@ -34,7 +35,10 @@ import { PRIVATE_PARENT_CAPABILITY_META_KEY, SESSION_ARTIFACT_PERSISTENCE_VERSION, SESSION_TRANSCRIPT_MAX_LIMIT, + TURN_RESULT_CODE_TEXT_TRUNCATED, + TURN_RESULT_TEXT_MAX_CHARS, TrustGateError, + normalizeTurnResultError, normalizeSnapshotPayload, ShellExecutionService, type InvocationContextV1, @@ -152,6 +156,7 @@ import type { BridgeRestoredSession, BridgeSessionGoal, BridgeSessionSummary, + BridgeTurnStatus, BridgePendingInteraction, BridgeClientRequestContext, CloseSessionOpts, @@ -968,6 +973,8 @@ interface SessionEntry { * tail of `sendPrompt`. */ pendingPromptList: PendingPromptEntry[]; + /** Recent formal terminals bridge-published before transcript visibility. */ + terminalTurnStatuses: Map; /** Bridge prompt that owns the child Guard wait for this FIFO. */ todoStopGuardAwaitingQueuedPromptOwnerPromptId?: string; /** @@ -1650,6 +1657,66 @@ type PromptTerminal = | { kind: 'cancelled' } | { kind: 'error'; err: unknown }; +const TERMINAL_TURN_STATUS_OVERLAY_LIMIT = 64; + +function truncateTurnText(text: string): { + text: string; + truncated: boolean; +} { + const truncated = text.length > TURN_RESULT_TEXT_MAX_CHARS; + return { + text: truncated ? text.slice(0, TURN_RESULT_TEXT_MAX_CHARS) : text, + truncated, + }; +} + +function rememberTerminalTurnStatus( + entry: SessionEntry, + pending: PendingPromptEntry, + terminal: PromptTerminal, +): void { + const promptText = truncateTurnText(pending.text); + const shared = { + sessionId: entry.sessionId, + promptId: pending.promptId, + promptText: promptText.text, + ...(promptText.truncated ? { promptTextTruncated: true } : {}), + queuedAt: pending.queuedAt, + ...(pending.startedAt !== undefined + ? { startedAt: pending.startedAt } + : {}), + endedAt: Date.now(), + ...(pending.originatorClientId !== undefined + ? { originatorClientId: pending.originatorClientId } + : {}), + }; + const status: BridgeTurnStatus = + terminal.kind === 'complete' + ? { + ...shared, + state: + terminal.result.stopReason === 'cancelled' + ? 'cancelled' + : 'completed', + ...(terminal.result.stopReason !== undefined + ? { stopReason: terminal.result.stopReason } + : {}), + } + : terminal.kind === 'cancelled' + ? { ...shared, state: 'cancelled', stopReason: 'cancelled' } + : { + ...shared, + state: 'error', + error: normalizeTurnResultError(terminal.err), + }; + entry.terminalTurnStatuses.set(pending.promptId, status); + while (entry.terminalTurnStatuses.size > TERMINAL_TURN_STATUS_OVERLAY_LIMIT) { + const oldest = entry.terminalTurnStatuses.keys().next().value; + if (oldest === undefined) break; + entry.terminalTurnStatuses.delete(oldest); + } +} + /** * Publish the formal terminal event for an accepted prompt exactly once. * All terminal paths (agent settle, queued removal, deadline, session @@ -1675,6 +1742,7 @@ function publishPromptTerminal( return; } pendingEntry.terminalPublished = true; + rememberTerminalTurnStatus(entry, pendingEntry, terminal); const originatorClientId = pendingEntry.originatorClientId; if (terminal.kind === 'complete') { broadcastTurnComplete( @@ -1777,6 +1845,117 @@ function extractPromptText( return hasImage ? '[image]' : ''; } +function liveTurnStatus( + sessionId: string, + pending: PendingPromptEntry, +): BridgeTurnStatus { + const promptText = truncateTurnText(pending.text); + return { + sessionId, + state: pending.state === 'running' ? 'running' : 'queued', + promptId: pending.promptId, + promptText: promptText.text, + ...(promptText.truncated ? { promptTextTruncated: true } : {}), + queuedAt: pending.queuedAt, + ...(pending.startedAt !== undefined + ? { startedAt: pending.startedAt } + : {}), + ...(pending.originatorClientId !== undefined + ? { originatorClientId: pending.originatorClientId } + : {}), + }; +} + +function findLiveTurnStatus( + entry: SessionEntry, + promptId?: string, +): BridgeTurnStatus | undefined { + const live = entry.pendingPromptList.filter( + (pending) => !pending.removed && !pending.terminalPublished, + ); + if (promptId !== undefined) { + const match = live.find((pending) => pending.promptId === promptId); + return match ? liveTurnStatus(entry.sessionId, match) : undefined; + } + const running = live.find((pending) => pending.state === 'running'); + if (running) return liveTurnStatus(entry.sessionId, running); + const queued = live.find((pending) => pending.state === 'queued'); + return queued ? liveTurnStatus(entry.sessionId, queued) : undefined; +} + +function settledTurnStatus( + sessionId: string, + record: TurnResultRecordPayload, +): BridgeTurnStatus { + return { + sessionId, + state: record.state, + promptId: record.promptId, + ...(record.stopReason !== undefined + ? { stopReason: record.stopReason } + : {}), + ...(record.error !== undefined ? { error: record.error } : {}), + ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}), + endedAt: record.endedAt, + ...(record.promptText !== undefined + ? { promptText: record.promptText } + : {}), + ...(record.promptTextTruncated !== undefined + ? { promptTextTruncated: record.promptTextTruncated } + : {}), + ...(record.resultText !== undefined + ? { resultText: record.resultText } + : {}), + ...(record.resultTruncated !== undefined + ? { resultTruncated: record.resultTruncated } + : {}), + ...(record.resultTruncated === true + ? { resultCode: record.resultCode ?? TURN_RESULT_CODE_TEXT_TRUNCATED } + : {}), + ...(record.originatorClientId !== undefined + ? { originatorClientId: record.originatorClientId } + : {}), + }; +} + +function enrichTerminalTurnStatus( + terminal: BridgeTurnStatus, + persisted: BridgeTurnStatus, +): BridgeTurnStatus { + return { + ...terminal, + ...(persisted.promptText !== undefined + ? { promptText: persisted.promptText } + : {}), + ...(persisted.promptTextTruncated !== undefined + ? { promptTextTruncated: persisted.promptTextTruncated } + : {}), + ...(persisted.resultText !== undefined + ? { resultText: persisted.resultText } + : {}), + ...(persisted.resultTruncated !== undefined + ? { resultTruncated: persisted.resultTruncated } + : {}), + ...(persisted.resultCode !== undefined + ? { resultCode: persisted.resultCode } + : {}), + ...(terminal.originatorClientId === undefined && + persisted.originatorClientId !== undefined + ? { originatorClientId: persisted.originatorClientId } + : {}), + }; +} + +function latestTerminalTurnStatus( + entry: SessionEntry, +): BridgeTurnStatus | undefined { + let latest: BridgeTurnStatus | undefined; + for (const status of entry.terminalTurnStatuses.values()) { + if ((status.endedAt ?? 0) >= (latest?.endedAt ?? 0)) latest = status; + } + return latest; +} + const DEFAULT_INIT_TIMEOUT_MS = 10_000; const PERSIST_TIMEOUT_MS = 5_000; // Bounded retries for the sub-session `parentSessionId` transcript write on the @@ -5057,6 +5236,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingPromptCount: 0, pendingAgentNotificationCount: 0, pendingPromptList: [], + terminalTurnStatuses: new Map(), midTurnMessageQueue: [], settledMidTurnMessageIds: [], promotedMidTurnMessageIds: [], @@ -7352,6 +7532,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } throw new DOMException('Prompt aborted', 'AbortError'); } + pendingEntry.startedAt = Date.now(); // If this prompt was queued behind another, promote it to // 'running' and publish a started event now that it has reached the // head of the FIFO. A promoted mid-turn message that starts @@ -9512,7 +9693,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Authorize the caller against this session — mirrors /prompt. resolveTrustedClientId(entry, context?.clientId); return entry.pendingPromptList - .filter((p) => !p.removed) + .filter((p) => !p.removed && !p.terminalPublished) .map((p) => ({ promptId: p.promptId, text: p.text, @@ -9524,6 +9705,74 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { })); }, + async getSessionTurnStatus(sessionId, context, promptId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + + const liveBeforeRead = findLiveTurnStatus(entry, promptId); + if (liveBeforeRead) return liveBeforeRead; + + const terminalBeforeRead = + promptId !== undefined + ? entry.terminalTurnStatuses.get(promptId) + : latestTerminalTurnStatus(entry); + let result: { + v: number; + sessionId: string; + turnResult: TurnResultRecordPayload | null; + }; + try { + result = await requestSessionStatus( + sessionId, + SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, + { ...(promptId !== undefined ? { promptId } : {}) }, + ); + } catch (error) { + const liveAfterFailure = findLiveTurnStatus(entry, promptId); + if (liveAfterFailure) return liveAfterFailure; + const terminalAfterFailure = + promptId !== undefined + ? entry.terminalTurnStatuses.get(promptId) + : latestTerminalTurnStatus(entry); + if (terminalAfterFailure ?? terminalBeforeRead) { + return terminalAfterFailure ?? terminalBeforeRead; + } + throw error; + } + const liveAfterRead = findLiveTurnStatus(entry, promptId); + if (liveAfterRead) return liveAfterRead; + const terminal = + promptId !== undefined + ? entry.terminalTurnStatuses.get(promptId) + : latestTerminalTurnStatus(entry); + const persisted = result.turnResult + ? settledTurnStatus(sessionId, result.turnResult) + : undefined; + if (promptId !== undefined) { + if (terminal && persisted) { + return enrichTerminalTurnStatus(terminal, persisted); + } + if (terminal) return terminal; + if (persisted) return persisted; + } else { + if (terminal && persisted && terminal.promptId === persisted.promptId) { + return enrichTerminalTurnStatus(terminal, persisted); + } + if (terminal && persisted) { + return (terminal.endedAt ?? 0) >= (persisted.endedAt ?? 0) + ? terminal + : persisted; + } + if (terminal) return terminal; + if (persisted) return persisted; + } + if (promptId !== undefined) { + return undefined; + } + return { sessionId, state: 'idle' as const }; + }, + removePendingPrompt(sessionId, promptId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -10155,6 +10404,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw err; } + entry.terminalTurnStatuses.clear(); + const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0; const filesChanged = (response['filesChanged'] as string[]) ?? []; const filesFailed = (response['filesFailed'] as string[]) ?? []; diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 359ec4a7b0d..d374474d95d 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -8,6 +8,8 @@ import type { ApprovalMode, GoalSnapshotV2, SessionGroupPresetColor, + TurnResultCode, + TurnResultErrorPayload, } from '@qwen-code/qwen-code-core'; import type { CancelNotification, @@ -863,6 +865,7 @@ export interface BridgeMidTurnMessagesSnapshot { export interface PendingPromptEntry { promptId: string; queuedAt: number; + startedAt?: number; originatorClientId?: string; promotedMidTurn?: true; text: string; @@ -904,6 +907,23 @@ export interface PendingPromptSummary { originatorClientId?: string; } +export interface BridgeTurnStatus { + sessionId: string; + state: 'idle' | 'queued' | 'running' | 'completed' | 'cancelled' | 'error'; + promptId?: string; + promptText?: string; + promptTextTruncated?: boolean; + queuedAt?: number; + startedAt?: number; + endedAt?: number; + stopReason?: string; + error?: TurnResultErrorPayload; + resultText?: string; + resultTruncated?: boolean; + resultCode?: TurnResultCode; + originatorClientId?: string; +} + export interface BridgeDaemonStatusLimits { maxSessions: number | null; maxPendingPromptsPerSession: number | null; @@ -1174,6 +1194,13 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): readonly PendingPromptSummary[]; + /** Read an exact prompt, or the current/newest turn when omitted. */ + getSessionTurnStatus( + sessionId: string, + context?: BridgeClientRequestContext, + promptId?: string, + ): Promise; + /** * Remove a specific prompt from the pending queue. For `queued` prompts, * aborts them so the FIFO skips dispatch. For `running` prompts, aborts diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index c88b3cfee35..1faf337d7cc 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -185,6 +185,8 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionGoalGet: 'qwen/control/session/goal/get', sessionMcpRuntimeAdd: 'qwen/control/session/mcp/runtime-add', sessionMcpRuntimeRemove: 'qwen/control/session/mcp/runtime-remove', + /** Read a bounded settled `turn_result` from the active transcript. */ + sessionTurnStatus: 'qwen/control/session/turn_status', workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', workspaceReload: 'qwen/control/workspace/reload', diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 1f3d585cce5..3166aec51df 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -198,6 +198,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ (await importOriginal()) .parseInvocationContext, ), + isTurnResultRecordPayload: ( + await importOriginal() + ).isTurnResultRecordPayload, SESSION_ARTIFACT_PERSISTENCE_VERSION: 2, GOAL_STATE_VERSION: 2, // The real helper: the goal get/clear fallbacks return its exact shape and @@ -9398,6 +9401,256 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('reads a settled turn_result record by promptId', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + const turnResult = { + promptId: 'prompt-1', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'hello', + resultText: 'world', + }; + const readPage = vi.fn().mockResolvedValue({ + sessionId, + records: [ + { type: 'user', subtype: undefined, message: {} }, + { type: 'system', subtype: 'turn_result', systemPayload: turnResult }, + ], + hasMore: false, + gaps: [], + startTime: 'start', + lastUpdated: 'end', + }); + vi.mocked(SessionTranscriptReader).mockImplementation( + () => + ({ + readPage, + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { + sessionId, + promptId: 'prompt-1', + }), + ).resolves.toEqual({ v: 1, sessionId, turnResult }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('still scans the transcript when the pre-read flush fails', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const flushSpy = vi + .fn() + .mockRejectedValue(new Error('ENOSPC: no space left on device')); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue({ + flush: flushSpy, + }); + const turnResult = { + promptId: 'prompt-1', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'hello', + resultText: 'world', + }; + const readPage = vi.fn().mockResolvedValue({ + sessionId, + records: [ + { type: 'system', subtype: 'turn_result', systemPayload: turnResult }, + ], + hasMore: false, + gaps: [], + startTime: 'start', + lastUpdated: 'end', + }); + vi.mocked(SessionTranscriptReader).mockImplementation( + () => + ({ + readPage, + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { + sessionId, + promptId: 'prompt-1', + }), + ).resolves.toEqual({ v: 1, sessionId, turnResult }); + expect(flushSpy).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('returns the most recent turn_result when no promptId is given', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + const older = { + promptId: 'prompt-old', + state: 'completed', + startedAt: 1000, + endedAt: 2000, + }; + const newer = { + promptId: 'prompt-new', + state: 'cancelled', + startedAt: 3000, + endedAt: 3500, + }; + const readPage = vi.fn().mockResolvedValue({ + sessionId, + records: [ + { type: 'system', subtype: 'turn_result', systemPayload: older }, + { type: 'assistant', subtype: undefined, message: {} }, + { type: 'system', subtype: 'turn_result', systemPayload: newer }, + ], + hasMore: false, + gaps: [], + startTime: 'start', + lastUpdated: 'end', + }); + vi.mocked(SessionTranscriptReader).mockImplementation( + () => + ({ + readPage, + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { + sessionId, + }), + ).resolves.toEqual({ v: 1, sessionId, turnResult: newer }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('returns turnResult null when no settled turn matches', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + const readPage = vi.fn().mockResolvedValue({ + sessionId, + records: [{ type: 'user', subtype: undefined, message: {} }], + hasMore: false, + gaps: [], + startTime: 'start', + lastUpdated: 'end', + }); + vi.mocked(SessionTranscriptReader).mockImplementation( + () => + ({ + readPage, + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { + sessionId, + promptId: 'missing', + }), + ).resolves.toEqual({ v: 1, sessionId, turnResult: null }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects turn status reads with invalid params or non-live sessions', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + for (const params of [ + {}, + { sessionId: '' }, + { sessionId, promptId: '' }, + { sessionId, promptId: 42 }, + ]) { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, params), + ).rejects.toThrow(); + } + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { + sessionId: '22222222-2222-2222-2222-222222222222', + }), + ).rejects.toThrow(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('newSession with SSE MCP server creates MCPServerConfig with url', async () => { await setupSessionMocks('session-sse'); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2b1e7dfa465..9a965524009 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -74,6 +74,7 @@ import { encodeSessionTranscriptCursor, findBoundaryAtOrBefore, isReplayTurnStartType, + isTurnResultRecordPayload, subagentGenerator, redactUrlCredentials, computeUniqueBranchTitle, @@ -120,6 +121,7 @@ import { type WorkspaceRememberContextMode, type ChatRecord, type ToolInvocationGuard, + type TurnResultRecordPayload, } from '@qwen-code/qwen-code-core'; import { randomUUID, timingSafeEqual } from 'node:crypto'; import { performance } from 'node:perf_hooks'; @@ -394,6 +396,46 @@ const ACP_REASONING_EFFORT_NAMES: Record = { // Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts. const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000; +const TURN_STATUS_SCAN_PAGE_LIMIT = 500; +const TURN_STATUS_SCAN_MAX_PAGES = 10; + +async function findSettledTurnResult( + reader: SessionTranscriptReader, + sessionId: string, + promptId: string | undefined, + workspaceCwd: string, +): Promise { + let cursor: string | undefined; + for (let page = 0; page < TURN_STATUS_SCAN_MAX_PAGES; page++) { + const result = await reader.readPage(sessionId, { + ...(cursor !== undefined + ? { cursor } + : { direction: 'backward' as const }), + limit: TURN_STATUS_SCAN_PAGE_LIMIT, + maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES, + }); + for (let i = result.records.length - 1; i >= 0; i--) { + const record = result.records[i]!; + if (record.type !== 'system' || record.subtype !== 'turn_result') { + continue; + } + const payload = record.systemPayload; + if (!isTurnResultRecordPayload(payload)) continue; + if (promptId === undefined || payload.promptId === promptId) { + return payload; + } + } + if (!result.hasMore || result.nextCursorState === undefined) { + return undefined; + } + cursor = encodeSessionTranscriptCursor( + result.nextCursorState, + workspaceCwd, + ); + } + return undefined; +} + type AcpSessionProfileStage = | 'settings_load' | 'live_restore' @@ -10598,6 +10640,101 @@ class QwenAgent implements Agent { : null, }; } + case SERVE_CONTROL_EXT_METHODS.sessionTurnStatus: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const rawPromptId = params['promptId']; + if ( + rawPromptId !== undefined && + (typeof rawPromptId !== 'string' || rawPromptId.length === 0) + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing promptId', + ); + } + const session = this.sessionOrThrow(sessionId); + const settings = loadSettingsCached(cwd); + return await runWithAcpRuntimeOutputDir(settings, cwd, async () => { + try { + await session.getConfig().getChatRecordingService()?.flush(); + } catch { + // Read the last durable snapshot after a best-effort flush. + } + let reader: SessionTranscriptReader | undefined; + try { + reader = new SessionTranscriptReader(cwd); + const turnResult = await findSettledTurnResult( + reader, + sessionId, + typeof rawPromptId === 'string' ? rawPromptId : undefined, + cwd, + ); + return { + v: 1, + sessionId, + turnResult: turnResult ?? null, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Transcript file not written yet (no settled turn + // persisted). Scoped to the read so an unrelated ENOENT + // (settings/runtime resolution) still surfaces. + return { v: 1, sessionId, turnResult: null }; + } + if ( + error instanceof SessionTranscriptSnapshotUnavailableError && + reader + ) { + try { + const transcript = await fs.stat( + reader.getSessionFilePath(sessionId), + ); + if (transcript.size === 0) { + return { v: 1, sessionId, turnResult: null }; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code === 'ENOENT') { + return { v: 1, sessionId, turnResult: null }; + } + } + } + if (error instanceof InvalidSessionTranscriptCursorError) { + throw new RequestError(-32602, error.message, { + errorKind: 'invalid_transcript_cursor', + }); + } + if (error instanceof SessionTranscriptSnapshotUnavailableError) { + throw new RequestError(-32010, error.message, { + errorKind: 'transcript_snapshot_unavailable', + sessionId, + }); + } + if (error instanceof SessionTranscriptTooLargeError) { + throw new RequestError(-32011, error.message, { + errorKind: 'transcript_too_large', + sessionId, + snapshotSize: error.snapshotSize, + maxBytes: error.maxBytes, + }); + } + if (error instanceof SessionTranscriptPageTooLargeError) { + throw new RequestError(-32012, error.message, { + errorKind: 'transcript_page_too_large', + sessionId, + pageBytes: error.pageBytes, + maxBytes: error.maxBytes, + }); + } + throw error; + } + }); + } case SERVE_CONTROL_EXT_METHODS.sessionContinue: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 76e57e632ce..ba2459afd8b 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -392,6 +392,7 @@ describe('Session', () => { let switchModelSpy: ReturnType; let getAvailableCommandsSpy: ReturnType; let mockChatRecordingService: { + recordTurnResult: ReturnType; recordUserMessage: ReturnType; recordGoalRuntimeMessage: ReturnType; recordMidTurnUserMessage: ReturnType; @@ -651,6 +652,7 @@ describe('Session', () => { }; mockChatRecordingService = { + recordTurnResult: vi.fn(), recordUserMessage: vi.fn(), recordGoalRuntimeMessage: vi.fn(), recordMidTurnUserMessage: vi.fn(), @@ -4114,6 +4116,530 @@ describe('Session', () => { expect(core.getInvocationContext()).toBeUndefined(); }); + describe('turn result recording', () => { + const trustedContext: core.InvocationContextV1 = { + version: 1, + sessionId: 'test-session-id', + promptId: 'daemon-prompt-id', + originatorClientId: 'client-1', + }; + + it('records a completed turn_result for a daemon-admitted prompt', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'trusted prompt' }], + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 1, + ); + const payload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(payload).toMatchObject({ + promptId: 'daemon-prompt-id', + state: 'completed', + stopReason: 'end_turn', + promptText: 'trusted prompt', + originatorClientId: 'client-1', + }); + expect(payload.startedAt).toBeLessThanOrEqual(payload.endedAt); + }); + + it('records daemon display text instead of internal prompt context', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [ + { + type: 'text', + text: 'internal channel instructions\n\nhello', + }, + ], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: 'hello', + }), + ); + }); + + it('accumulates streamed agent text into resultText', async () => { + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello, ' }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'world!' }] } }], + }, + }, + ]), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'greet' }], + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + state: 'completed', + resultText: 'Hello, world!', + }), + ); + }); + + it('records only the final answer after a tool-call boundary', async () => { + mockToolRegistry.getTool.mockReturnValue({ + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + execute: vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }), + }), + }); + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { content: { parts: [{ text: 'I will check first. ' }] } }, + ], + functionCalls: [ + { + id: 'call-1', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { parts: [{ text: 'The final answer is 42.' }] }, + }, + ], + }, + }, + ]), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'read the file' }], + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ resultText: 'The final answer is 42.' }), + ); + }); + + it('excludes subagent updates and leaves resultText absent without a parent answer', async () => { + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + (async function* () { + await session.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'x'.repeat(core.TURN_RESULT_TEXT_MAX_CHARS + 1), + }, + _meta: { parentToolCallId: 'subagent-call' }, + }); + yield* createEmptyStream(); + })(), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'delegate this' }], + }, + trustedContext, + ); + + const payload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(payload.resultText).toBeUndefined(); + expect(payload.resultTruncated).toBeUndefined(); + }); + + it('caps resultText at TURN_RESULT_TEXT_MAX_CHARS and flags truncation', async () => { + const longText = 'a'.repeat(core.TURN_RESULT_TEXT_MAX_CHARS + 100); + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: longText }] } }], + }, + }, + ]), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'long' }], + }, + trustedContext, + ); + + const payload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(payload.resultText).toHaveLength( + core.TURN_RESULT_TEXT_MAX_CHARS, + ); + expect(payload.resultTruncated).toBe(true); + expect(payload.resultCode).toBe('RESULT_TEXT_TRUNCATED'); + }); + + it('records a cancelled turn when admission aborts before dispatch', async () => { + let releaseAdmission!: () => void; + const admission = new Promise((resolve) => { + releaseAdmission = resolve; + }); + mockConfig.assertCanStartTurn = vi.fn().mockReturnValue(admission); + const cancellation = new AbortController(); + + const prompt = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'cancelled prompt' }], + }, + trustedContext, + cancellation.signal, + ); + await vi.waitFor(() => + expect(mockConfig.assertCanStartTurn).toHaveBeenCalledOnce(), + ); + cancellation.abort(); + releaseAdmission(); + + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }); + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + state: 'cancelled', + }), + ); + }); + + it('records admission errors without a startedAt timestamp', async () => { + mockConfig.assertCanStartTurn = vi + .fn() + .mockRejectedValueOnce(new Error('writer unavailable')); + + await expect( + session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'cannot start' }], + }, + trustedContext, + ), + ).rejects.toThrow('writer unavailable'); + + const payload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(payload).toMatchObject({ + promptId: 'daemon-prompt-id', + state: 'error', + error: { message: 'writer unavailable' }, + }); + expect(payload.startedAt).toBeUndefined(); + }); + + it('records an error turn when the model stream fails', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createFailingStream('model exploded')); + + await expect( + session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'boom' }], + }, + trustedContext, + ), + ).rejects.toThrow('model exploded'); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + state: 'error', + error: { message: 'model exploded' }, + }), + ); + }); + + it('records a cancelled turn when user cancel races a non-abort stream error', async () => { + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createFailingStream('Request was aborted.', () => { + void session.cancelPendingPrompt(); + }), + ); + + await expect( + session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'cancel me' }], + }, + trustedContext, + ), + ).resolves.toEqual({ stopReason: 'cancelled' }); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + state: 'cancelled', + }), + ); + }); + + it('does not record a turn_result without an invocation context', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'standalone prompt' }], + }); + + expect( + mockChatRecordingService.recordTurnResult, + ).not.toHaveBeenCalled(); + }); + + it('keeps overlapping turn records attributed to their own promptIds', async () => { + // DAEMON-003 overlap: the bridge releases the FIFO on deadline + // while the agent is still executing, so the successor prompt is + // admitted before the predecessor settles. Each turn must still + // settle its own record under its own promptId. + mockConfig.assertCanStartTurn = vi.fn().mockResolvedValue(undefined); + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstStream = (async function* () { + yield { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'first answer' }] } }], + }, + }; + await firstBlocked; + })(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(firstStream) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { content: { parts: [{ text: 'second answer' }] } }, + ], + }, + }, + ]), + ); + + const first = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'first prompt' }], + }, + trustedContext, + ); + await vi.waitFor(() => + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1), + ); + + const secondContext: core.InvocationContextV1 = { + version: 1, + sessionId: 'test-session-id', + promptId: 'second-prompt-id', + originatorClientId: 'client-1', + }; + const second = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'second prompt' }], + }, + secondContext, + ); + // Third assertCanStartTurn call = the successor's prompt()-level + // admission (turn A used the prompt + inner calls). Flush + // microtasks so the successor's recording begin has run. + await vi.waitFor(() => + expect(mockConfig.assertCanStartTurn).toHaveBeenCalledTimes(3), + ); + await Promise.resolve(); + await Promise.resolve(); + + releaseFirst(); + await first; + await second; + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 2, + ); + const payloads = + mockChatRecordingService.recordTurnResult.mock.calls.map( + (call) => call[0], + ); + const firstPayload = payloads.find( + (payload) => payload.promptId === 'daemon-prompt-id', + ); + const secondPayload = payloads.find( + (payload) => payload.promptId === 'second-prompt-id', + ); + expect(firstPayload).toMatchObject({ + promptId: 'daemon-prompt-id', + state: 'cancelled', + promptText: 'first prompt', + resultText: 'first answer', + }); + expect(secondPayload).toMatchObject({ + promptId: 'second-prompt-id', + state: 'completed', + promptText: 'second prompt', + resultText: 'second answer', + }); + }); + + it('records a cancelled turn when cancelled while waiting for the predecessor', async () => { + // The successor is admitted while the predecessor still streams, + // then cancelled during the predecessor wait; it must settle its + // own cancelled record without disturbing the predecessor's. + mockConfig.assertCanStartTurn = vi.fn().mockResolvedValue(undefined); + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstStream = (async function* () { + yield { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [] } }], + }, + }; + await firstBlocked; + })(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(firstStream) + .mockResolvedValueOnce(createEmptyStream()); + + const first = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'first prompt' }], + }, + trustedContext, + ); + await vi.waitFor(() => + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1), + ); + + const secondContext: core.InvocationContextV1 = { + version: 1, + sessionId: 'test-session-id', + promptId: 'second-prompt-id', + }; + const admissionCancellation = new AbortController(); + const second = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'second prompt' }], + }, + secondContext, + admissionCancellation.signal, + ); + await vi.waitFor(() => + expect(mockConfig.assertCanStartTurn).toHaveBeenCalledTimes(3), + ); + await Promise.resolve(); + await Promise.resolve(); + admissionCancellation.abort(); + + releaseFirst(); + await first; + await expect(second).resolves.toEqual({ stopReason: 'cancelled' }); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 2, + ); + const payloads = + mockChatRecordingService.recordTurnResult.mock.calls.map( + (call) => call[0], + ); + const firstPayload = payloads.find( + (payload) => payload.promptId === 'daemon-prompt-id', + ); + const secondPayload = payloads.find( + (payload) => payload.promptId === 'second-prompt-id', + ); + expect(firstPayload).toMatchObject({ + promptId: 'daemon-prompt-id', + promptText: 'first prompt', + }); + expect(secondPayload).toMatchObject({ + promptId: 'second-prompt-id', + state: 'cancelled', + promptText: 'second prompt', + }); + }); + }); + it('rejects a trusted context for a different session', async () => { const trustedContext: core.InvocationContextV1 = { version: 1, @@ -11538,6 +12064,55 @@ describe('Session', () => { }); }); + it('does not apply the turn-result limit to channel delivery text', async () => { + const answer = 'x'.repeat(core.TURN_RESULT_TEXT_MAX_CHARS + 100); + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: answer }] } }], + }, + }, + ]), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'long delivery' }], + _meta: { + 'qwen.daemon.channelDelivery': { + deliveryId: 'prompt-long-delivery', + target: { + channelName: 'dingtalk', + type: 'user', + id: 'user-1', + }, + }, + }, + }, + { + version: 1, + sessionId: 'test-session-id', + promptId: 'prompt-long-delivery', + }, + ); + + await vi.waitFor(() => { + expect(mockClient.extMethod).toHaveBeenCalledWith( + 'qwen/control/channel-delivery', + expect.objectContaining({ text: answer }), + ); + }); + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + resultText: answer.slice(0, core.TURN_RESULT_TEXT_MAX_CHARS), + resultTruncated: true, + }), + ); + }); + it('delivers only the final tool-free response block for a prompt', async () => { mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); mockToolRegistry.getTool.mockReturnValue({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index e79cceaca49..db42ceafb02 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -50,6 +50,7 @@ import type { CronTaskDelivery, InvocationContextV1, WorkflowApproval, + TurnResultRecordPayload, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -177,6 +178,9 @@ import { getFullTurnVisionModelSelector, splitImageParts, approxBase64Bytes, + normalizeTurnResultError, + TURN_RESULT_CODE_TEXT_TRUNCATED, + TURN_RESULT_TEXT_MAX_CHARS, runWithRuntimeContentGenerator, getInvocationContext, runWithInvocationContext, @@ -964,6 +968,43 @@ function hasInlineMediaContentBlock(content: ContentBlock[]): boolean { return content.some((part) => part.type === 'image' || part.type === 'audio'); } +/** + * Extract the prompt text recorded in a `turn_result` record. Mirrors the + * bridge's pending-prompt `extractPromptText`: first non-empty text block, + * an image placeholder for image-only prompts, else empty. + */ +function extractTurnPromptText(content: ContentBlock[]): string { + let hasImage = false; + for (const block of content) { + if (block.type === 'image') { + hasImage = true; + } + if (block.type === 'text' && block.text.length > 0) { + return block.text; + } + } + return hasImage ? '[image]' : ''; +} + +type InFlightTurnRecording = { + promptId: string; + originatorClientId?: string; + startedAt?: number; + promptText: string; + promptTextTruncated: boolean; + finalAnswer: FinalAnswerCapture; +}; + +function truncateTurnText(text: string): { + text: string; + truncated: boolean; +} { + if (text.length <= TURN_RESULT_TEXT_MAX_CHARS) { + return { text, truncated: false }; + } + return { text: text.slice(0, TURN_RESULT_TEXT_MAX_CHARS), truncated: true }; +} + function capMidTurnDrainItems(items: T[], fieldName: string): T[] { if (items.length <= MAX_MID_TURN_DRAIN_ITEMS) return items; @@ -1168,25 +1209,37 @@ interface PromptChannelDelivery { target: CronTaskDelivery['target']; } -interface ChannelDeliveryCapture { +interface FinalAnswerCapture { finalText: string; } -function beginChannelDeliveryResponseBlock( - capture: ChannelDeliveryCapture | undefined, -): string[] | undefined { +interface FinalAnswerResponseBlock { + text: string; +} + +function beginFinalAnswerResponseBlock( + capture: FinalAnswerCapture | undefined, +): FinalAnswerResponseBlock | undefined { if (!capture) return undefined; capture.finalText = ''; - return []; + return { text: '' }; } -function commitChannelDeliveryResponseBlock( - capture: ChannelDeliveryCapture | undefined, - responseBlock: string[] | undefined, +function appendFinalAnswerText( + responseBlock: FinalAnswerResponseBlock | undefined, + text: string, +): void { + if (!responseBlock) return; + responseBlock.text += text; +} + +function commitFinalAnswerResponseBlock( + capture: FinalAnswerCapture | undefined, + responseBlock: FinalAnswerResponseBlock | undefined, hasFunctionCalls: boolean, ): void { if (capture && responseBlock && !hasFunctionCalls) { - capture.finalText = responseBlock.join(''); + capture.finalText = responseBlock.text; } } @@ -3433,37 +3486,61 @@ export class Session implements SessionContext { modelPrompt?: string, scheduledGoalTurn?: AcpGoalTurn, ): Promise { - if (this.closing) { - throw RequestError.invalidParams(undefined, 'Session is closing'); - } - if (modelPrompt !== undefined && invocationContext === undefined) { - throw RequestError.invalidParams( - undefined, - 'Model-only prompt requires trusted invocation context', - ); - } - if (modelPrompt !== undefined && !isValidTrustedModelPrompt(modelPrompt)) { + if ( + invocationContext !== undefined && + invocationContext.sessionId !== this.config.getSessionId() + ) { throw RequestError.invalidParams( undefined, - 'Invalid trusted model-only prompt', + 'Invocation context session does not match the active session', ); } - await this.assertCanStartTurn(); - if ( - this.liveScreenContextTool || - this.liveTaskTools.length > 0 || - this.liveSpeakToUserTool - ) { - await this.#syncLiveToolDeclarations(); - } - if (this.closing) { - throw RequestError.invalidParams(undefined, 'Session is closing'); + const turnRecording = this.#beginTurnRecording(params, invocationContext); + try { + if (this.closing) { + throw RequestError.invalidParams(undefined, 'Session is closing'); + } + if (modelPrompt !== undefined && invocationContext === undefined) { + throw RequestError.invalidParams( + undefined, + 'Model-only prompt requires trusted invocation context', + ); + } + if ( + modelPrompt !== undefined && + !isValidTrustedModelPrompt(modelPrompt) + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid trusted model-only prompt', + ); + } + await this.assertCanStartTurn(); + if ( + this.liveScreenContextTool || + this.liveTaskTools.length > 0 || + this.liveSpeakToUserTool + ) { + await this.#syncLiveToolDeclarations(); + } + if (this.closing) { + throw RequestError.invalidParams(undefined, 'Session is closing'); + } + } catch (error) { + this.#settleTurnRecording('error', turnRecording, undefined, error); + throw error; } if (admissionCancellation?.aborted) { + this.#settleTurnRecording('cancelled', turnRecording); return { stopReason: 'cancelled' }; } - const todoStopGuardPreparation = - this.#prepareTodoStopGuardForPrompt(params); + let todoStopGuardPreparation: TodoStopGuardPromptPreparation; + try { + todoStopGuardPreparation = this.#prepareTodoStopGuardForPrompt(params); + } catch (error) { + this.#settleTurnRecording('error', turnRecording, undefined, error); + throw error; + } let goalTurn = scheduledGoalTurn; let reservedGoalRuntime: GoalRuntime | undefined; let reservedGoalTurnKey: string | undefined; @@ -3488,7 +3565,10 @@ export class Session implements SessionContext { } } } catch (error) { - if (!(error instanceof GoalPersistenceUnavailableError)) throw error; + if (!(error instanceof GoalPersistenceUnavailableError)) { + this.#settleTurnRecording('error', turnRecording, undefined, error); + throw error; + } } } // After writer admission, install this prompt's AbortController before @@ -3599,6 +3679,7 @@ export class Session implements SessionContext { releasePendingSend(); this.todoStopGuard.suspend(); } + this.#settleTurnRecording('error', turnRecording, undefined, error); throw error; } } @@ -3614,10 +3695,18 @@ export class Session implements SessionContext { // every later goal turn blocks behind it. Releasing an unclaimed // reservation is a no-op, so the wider guard costs nothing. if (reservedGoalRuntime && reservedGoalTurnKey) { - await reservedGoalRuntime.releaseTurn(reservedGoalTurnKey); + try { + await reservedGoalRuntime.releaseTurn(reservedGoalTurnKey); + } catch (error) { + releasePendingSend(); + this.todoStopGuard.suspend(); + this.#settleTurnRecording('error', turnRecording, undefined, error); + throw error; + } } releasePendingSend(); this.todoStopGuard.suspend(); + this.#settleTurnRecording('cancelled', turnRecording); return { stopReason: 'cancelled' }; } @@ -3629,9 +3718,9 @@ export class Session implements SessionContext { this.duplicateProviderToolCallResponseIds.clear(); const channelDelivery = parsePromptChannelDelivery(params); - const channelDeliveryCapture = channelDelivery - ? { finalText: '' } - : undefined; + const finalAnswerCapture = + turnRecording?.finalAnswer ?? + (channelDelivery ? { finalText: '' } : undefined); // Track this prompt's completion for the next prompt to await let resolveCompletion!: () => void; @@ -3641,16 +3730,22 @@ export class Session implements SessionContext { let promptResult: PromptResponse | undefined; let promptFailed = false; + let promptError: unknown; + let promptThrew = false; + let turnState: 'completed' | 'cancelled' | 'error' = 'error'; + let turnError: unknown; + if (turnRecording) turnRecording.startedAt = Date.now(); try { const result = await this.#executePrompt( params, pendingSend, - channelDeliveryCapture, + finalAnswerCapture, invocationContext, modelPrompt, goalTurn, ); promptResult = result; + turnState = result.stopReason === 'cancelled' ? 'cancelled' : 'completed'; releasePendingSend(); // Drain any cron prompts that queued while the prompt was active void this.#drainCronQueue(); @@ -3663,21 +3758,29 @@ export class Session implements SessionContext { source: 'prompt', target: channelDelivery.target, text: normalizeChannelDeliveryText( - channelDeliveryCapture?.finalText ?? '', + finalAnswerCapture?.finalText ?? '', ), promptId: channelDelivery.deliveryId, }); } - return result; } catch (error) { promptFailed = true; - if (error instanceof SessionWriterError) { - throw new RequestError(error.rpcCode, error.message, { - errorKind: error.errorKind, - }); - } - throw error; - } finally { + promptThrew = true; + turnState = + pendingSend.signal.aborted && + pendingSend.signal.reason === USER_CANCEL_ABORT_REASON + ? 'cancelled' + : 'error'; + turnError = error; + promptError = + error instanceof SessionWriterError + ? new RequestError(error.rpcCode, error.message, { + errorKind: error.errorKind, + }) + : error; + } + + try { const stillOwnsPendingPrompt = this.pendingPrompt === pendingSend; releasePendingSend(); const shouldDrainAutomaticQueues = @@ -3698,17 +3801,28 @@ export class Session implements SessionContext { } else if (reservedGoalRuntime && reservedGoalTurnKey) { await reservedGoalRuntime.releaseTurn(reservedGoalTurnKey); } - // Start the scheduler in finally, not the success path: a turn can arm - // a wakeup via LoopWakeup and then throw on a later step. Gated on - // hasPendingWork/disposed/disabled, so it only starts when a wakeup (or - // cron job) is actually pending — otherwise the loop dies silently on - // any post-arm error. + // Start the scheduler during cleanup, not only on success: a turn can + // arm a wakeup via LoopWakeup and then throw on a later step. void this.#startCronSchedulerInRuntime(); resolveCompletion(); this.pendingPromptCompletion = null; void this.#drainGoalQueue(); await this.#consumeLiveEndInstruction(); + } catch (error) { + turnState = 'error'; + turnError = error; + promptError = error; + promptThrew = true; } + + this.#settleTurnRecording( + turnState, + turnRecording, + promptResult, + turnError, + ); + if (promptThrew) throw promptError; + return promptResult!; } /** @@ -3872,7 +3986,7 @@ export class Session implements SessionContext { async #executePrompt( params: PromptRequest, pendingSend: AbortController, - channelDeliveryCapture?: ChannelDeliveryCapture, + channelDeliveryCapture?: FinalAnswerCapture, invocationContext?: InvocationContextV1, modelPrompt?: string, goalTurn?: AcpGoalTurn, @@ -3911,7 +4025,7 @@ export class Session implements SessionContext { async #executePromptInner( params: PromptRequest, pendingSend: AbortController, - channelDeliveryCapture?: ChannelDeliveryCapture, + channelDeliveryCapture?: FinalAnswerCapture, modelPrompt?: string, goalTurn?: AcpGoalTurn, ): Promise { @@ -4340,7 +4454,9 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( pendingSend.signal, ); - let channelDeliveryResponseBlock: string[] | undefined; + let channelDeliveryResponseBlock: + | FinalAnswerResponseBlock + | undefined; try { // Set where the model request is actually issued, not at @@ -4377,10 +4493,12 @@ export class Session implements SessionContext { } const responseStream = sendResult.responseStream; nextMessage = null; - channelDeliveryResponseBlock = - beginChannelDeliveryResponseBlock(channelDeliveryCapture); - const channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + channelDeliveryResponseBlock = beginFinalAnswerResponseBlock( + channelDeliveryCapture, + ); + const channelDeliveryCheckpoint = channelDeliveryResponseBlock + ? { ...channelDeliveryResponseBlock } + : undefined; let streamFailed = false; try { @@ -4407,7 +4525,10 @@ export class Session implements SessionContext { part.thought, ); if (!part.thought) { - channelDeliveryResponseBlock?.push(part.text); + appendFinalAnswerText( + channelDeliveryResponseBlock, + part.text, + ); messageDisplay?.addChunk(part.text); } } @@ -4435,9 +4556,14 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; + if ( + channelDeliveryResponseBlock && + channelDeliveryCheckpoint + ) { + Object.assign( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } } await finalizeToolCallPreparations( @@ -4532,7 +4658,7 @@ export class Session implements SessionContext { await messageDisplay?.finish(); } - commitChannelDeliveryResponseBlock( + commitFinalAnswerResponseBlock( channelDeliveryCapture, channelDeliveryResponseBlock, functionCalls.length > 0, @@ -4664,7 +4790,7 @@ export class Session implements SessionContext { messageBus: MessageBus | undefined, allowExternalHooks = true, modelOverride?: string, - channelDeliveryCapture?: ChannelDeliveryCapture, + channelDeliveryCapture?: FinalAnswerCapture, ): Promise<{ stopReason: PromptResponse['stopReason'] }> { const stopHookBlockingCap = this.config.getStopHookBlockingCap(); let stopHookIterationCount = 0; @@ -4987,7 +5113,7 @@ export class Session implements SessionContext { onAutomaticContinuationValidated?: () => Promise; onFullTurnModel?: (model: string) => boolean; getModelOverride?: () => string | undefined; - channelDeliveryCapture?: ChannelDeliveryCapture; + channelDeliveryCapture?: FinalAnswerCapture; } = {}, ): Promise { let nextMessage: Content | null = { role: 'user', parts }; @@ -5057,7 +5183,7 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( pendingSend.signal, ); - let channelDeliveryResponseBlock: string[] | undefined; + let channelDeliveryResponseBlock: FinalAnswerResponseBlock | undefined; let providerSendChat: GeminiChat | undefined; let userContentPushCountBeforeSend = 0; @@ -5355,11 +5481,12 @@ export class Session implements SessionContext { const responseStream = sendResult.responseStream; nextMessage = null; - channelDeliveryResponseBlock = beginChannelDeliveryResponseBlock( + channelDeliveryResponseBlock = beginFinalAnswerResponseBlock( options.channelDeliveryCapture, ); - const channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + const channelDeliveryCheckpoint = channelDeliveryResponseBlock + ? { ...channelDeliveryResponseBlock } + : undefined; initialSend = false; if (guardForThisSend) { const guardCommitted = this.todoStopGuard.commitContinuation( @@ -5399,7 +5526,7 @@ export class Session implements SessionContext { part.thought, ); if (!part.thought) { - channelDeliveryResponseBlock?.push(part.text); + appendFinalAnswerText(channelDeliveryResponseBlock, part.text); messageDisplay?.addChunk(part.text); } } @@ -5426,8 +5553,11 @@ export class Session implements SessionContext { response.type === StreamEventType.MODEL_FALLBACK || !response.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = channelDeliveryCheckpoint; + if (channelDeliveryResponseBlock && channelDeliveryCheckpoint) { + Object.assign( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } } await finalizeToolCallPreparations( @@ -5504,7 +5634,7 @@ export class Session implements SessionContext { } } - commitChannelDeliveryResponseBlock( + commitFinalAnswerResponseBlock( options.channelDeliveryCapture, channelDeliveryResponseBlock, functionCalls.length > 0, @@ -5718,6 +5848,76 @@ export class Session implements SessionContext { timer.unref(); } + #beginTurnRecording( + params: PromptRequest, + invocationContext: InvocationContextV1 | undefined, + ): InFlightTurnRecording | null { + if (!invocationContext) { + return null; + } + const promptMetadata = (params as { _meta?: Record }) + ._meta; + const promptDisplayText = + typeof promptMetadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === 'string' + ? promptMetadata[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + : undefined; + const { text, truncated } = truncateTurnText( + promptDisplayText ?? extractTurnPromptText(params.prompt), + ); + return { + promptId: invocationContext.promptId, + ...(invocationContext.originatorClientId !== undefined + ? { originatorClientId: invocationContext.originatorClientId } + : {}), + promptText: text, + promptTextTruncated: truncated, + finalAnswer: { finalText: '' }, + }; + } + + #settleTurnRecording( + state: 'completed' | 'cancelled' | 'error', + recording: InFlightTurnRecording | null, + response?: PromptResponse, + error?: unknown, + ): void { + if (recording === null) { + return; + } + const finalAnswer = truncateTurnText(recording.finalAnswer.finalText); + const payload: TurnResultRecordPayload = { + promptId: recording.promptId, + state, + ...(response?.stopReason !== undefined + ? { stopReason: response.stopReason } + : {}), + ...(state === 'error' ? { error: normalizeTurnResultError(error) } : {}), + ...(recording.startedAt !== undefined + ? { startedAt: recording.startedAt } + : {}), + endedAt: Date.now(), + promptText: recording.promptText, + ...(recording.promptTextTruncated ? { promptTextTruncated: true } : {}), + ...(finalAnswer.text.length > 0 ? { resultText: finalAnswer.text } : {}), + ...(finalAnswer.truncated + ? { + resultTruncated: true, + resultCode: TURN_RESULT_CODE_TEXT_TRUNCATED, + } + : {}), + ...(recording.originatorClientId !== undefined + ? { originatorClientId: recording.originatorClientId } + : {}), + }; + try { + this.config.getChatRecordingService()?.recordTurnResult(payload); + } catch (recordError) { + debugLogger.warn( + `Failed to record turn result: ${this.#formatError(recordError)}`, + ); + } + } + #getCurrentChat(): GeminiChat { return this.config.getGeminiClient()!.getChat(); } @@ -6895,9 +7095,10 @@ export class Session implements SessionContext { } const responseStream = sendResult.responseStream; const channelDeliveryResponseBlock = - beginChannelDeliveryResponseBlock(channelDeliveryCapture); - const channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + beginFinalAnswerResponseBlock(channelDeliveryCapture); + const channelDeliveryCheckpoint = channelDeliveryResponseBlock + ? { ...channelDeliveryResponseBlock } + : undefined; if (loopTick && turnCount === 1) { // The block reached the model (the send started); commit it so // the next tick can detect "unchanged". Deferring the commit @@ -6932,7 +7133,10 @@ export class Session implements SessionContext { part.thought, ); if (!part.thought) { - channelDeliveryResponseBlock?.push(part.text); + appendFinalAnswerText( + channelDeliveryResponseBlock, + part.text, + ); messageDisplay?.addChunk(part.text); } } @@ -6960,9 +7164,14 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; + if ( + channelDeliveryResponseBlock && + channelDeliveryCheckpoint + ) { + Object.assign( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } } await finalizeToolCallPreparations( @@ -6990,7 +7199,7 @@ export class Session implements SessionContext { } } - commitChannelDeliveryResponseBlock( + commitFinalAnswerResponseBlock( channelDeliveryCapture, channelDeliveryResponseBlock, functionCalls.length > 0, diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 0144b4c830a..ff4feda9618 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -83,6 +83,7 @@ export type { BridgeGenerationStreamEvent, BridgeWorkspaceGenerationStreamEvent, BridgeSessionSummary, + BridgeTurnStatus, SessionMetadataUpdate, BridgeClientRequestContext, BridgeHeartbeatResult, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 39a60718cbf..68a64d4bc83 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -52,6 +52,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_source_metadata: { since: 'v1' }, session_side_task: { since: 'v1' }, session_prompt: { since: 'v1' }, + session_turn_status: { since: 'v1' }, session_mid_turn_message_mutation: { since: 'v1' }, // Daemon-owned reconciliation surface for mid-turn messages: // `GET /session/:id/mid-turn-messages` returns the messages still waiting diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 8ccd647dd80..2b9b7bb1026 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -4877,6 +4877,76 @@ export function registerSessionRoutes( ), ); + // Register `current` before the parameter route so it is not a promptId. + app.get('/session/:id/turns/current', (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + const runtime = resolveLiveSessionRuntime( + sessionId, + res, + 'GET /session/:id/turns/current', + ); + if (!runtime) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + void (async () => { + try { + const status = await runtime.bridge.getSessionTurnStatus( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(status); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/turns/current', + sessionId, + }); + } + })(); + }); + + app.get('/session/:id/turns/:promptId', (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + const runtime = resolveLiveSessionRuntime( + sessionId, + res, + 'GET /session/:id/turns/:promptId', + ); + if (!runtime) return; + const promptId = req.params['promptId']; + if (!promptId) { + res.status(400).json({ error: '`promptId` route parameter is required' }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + void (async () => { + try { + const status = await runtime.bridge.getSessionTurnStatus( + sessionId, + clientId !== undefined ? { clientId } : undefined, + promptId, + ); + if (!status) { + res.status(404).json({ + error: `Prompt ${promptId} not found in session ${sessionId}`, + code: 'prompt_not_found', + sessionId, + promptId, + }); + return; + } + res.status(200).json(status); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/turns/:promptId', + sessionId, + }); + } + })(); + }); + app.post( '/session/:id/shell', mutate({ strict: true }), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index f06d884d28c..676082e8285 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -111,6 +111,7 @@ import { type BridgeDaemonStatusSnapshot, type BridgeRestoredSession, type BridgeClientRequestContext, + type BridgeTurnStatus, type BridgeRestoreSessionRequest, type BridgeSession, type BridgeSessionSummary, @@ -406,6 +407,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_source_metadata', 'session_side_task', 'session_prompt', + 'session_turn_status', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', 'session_cancel', @@ -679,6 +681,11 @@ interface FakeBridgeOpts { sessionId: string, promptId: string, ) => { removed: boolean }; + getSessionTurnStatusImpl?: ( + sessionId: string, + context: BridgeClientRequestContext | undefined, + promptId: string | undefined, + ) => Promise; spawnImpl?: (req: BridgeSpawnRequest) => Promise; changeSessionCwdImpl?: ( sessionId: string, @@ -938,6 +945,7 @@ interface FakeBridge extends AcpSessionBridge { NonNullable >[0]; calls: BridgeSpawnRequest[]; + getSessionTurnStatusCalls: Array<{ sessionId: string; promptId?: string }>; loadCalls: BridgeRestoreSessionRequest[]; resumeCalls: BridgeRestoreSessionRequest[]; promptCalls: Array<{ @@ -1187,6 +1195,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }> = []; const removePendingPromptImpl = opts.removePendingPromptImpl ?? (() => ({ removed: true })); + const getSessionTurnStatusCalls: Array<{ + sessionId: string; + promptId?: string; + }> = []; + const getSessionTurnStatusImpl = + opts.getSessionTurnStatusImpl ?? (async () => undefined); const permissionVotes: FakeBridge['permissionVotes'] = []; const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; @@ -1767,6 +1781,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { liveSpeakToUserHandler = handler; }, calls, + getSessionTurnStatusCalls, loadCalls, resumeCalls, promptCalls, @@ -2196,6 +2211,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { removePendingPromptCalls.push({ sessionId, promptId }); return removePendingPromptImpl(sessionId, promptId); }, + async getSessionTurnStatus(sessionId, context, promptId) { + getSessionTurnStatusCalls.push({ + sessionId, + ...(promptId !== undefined ? { promptId } : {}), + }); + return getSessionTurnStatusImpl(sessionId, context, promptId); + }, async executeShellCommand(sessionId, command, signal, context) { shellCalls.push({ sessionId, @@ -9611,6 +9633,106 @@ describe('createServeApp', () => { }); }); + describe('GET /session/:id/turns', () => { + const turnsApp = (bridge: FakeBridge) => + createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + it('200 with the current turn status', async () => { + const bridge = fakeBridge({ + getSessionTurnStatusImpl: async (sessionId) => ({ + sessionId, + state: 'running', + promptId: 'p-1', + promptText: 'doing things', + queuedAt: 1000, + startedAt: 1100, + }), + }); + const res = await request(turnsApp(bridge)) + .get('/session/s-1/turns/current') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + sessionId: 's-1', + state: 'running', + promptId: 'p-1', + }); + expect(bridge.getSessionTurnStatusCalls).toEqual([{ sessionId: 's-1' }]); + }); + + it('200 with a settled turn status by promptId', async () => { + const bridge = fakeBridge({ + getSessionTurnStatusImpl: async (sessionId, _context, promptId) => ({ + sessionId, + state: 'completed', + promptId: promptId ?? 'p-1', + stopReason: 'end_turn', + resultText: 'done', + startedAt: 1000, + endedAt: 2000, + }), + }); + const res = await request(turnsApp(bridge)) + .get('/session/s-1/turns/p-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + sessionId: 's-1', + state: 'completed', + promptId: 'p-1', + stopReason: 'end_turn', + resultText: 'done', + }); + }); + + it('404 prompt_not_found when the bridge resolves nothing', async () => { + const bridge = fakeBridge({ + getSessionTurnStatusImpl: async () => undefined, + }); + const res = await request(turnsApp(bridge)) + .get('/session/s-1/turns/nope') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(404); + expect(res.body.code).toBe('prompt_not_found'); + expect(res.body.promptId).toBe('nope'); + }); + + it('404 for unknown session', async () => { + const bridge = fakeBridge({ + getSessionTurnStatusImpl: async () => { + throw new SessionNotFoundError('unknown'); + }, + }); + const res = await request(turnsApp(bridge)) + .get('/session/unknown/turns/current') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(404); + }); + + it('400 when bridge throws InvalidClientIdError', async () => { + const bridge = fakeBridge({ + getSessionTurnStatusImpl: async () => { + throw new InvalidClientIdError('s-1', 'rogue'); + }, + }); + const res = await request(turnsApp(bridge)) + .get('/session/s-1/turns/current') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'rogue'); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + }); + }); + describe('host allowlist (loopback bind)', () => { it('rejects requests with an unrelated Host header', async () => { const app = createServeApp(baseOpts); diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index 3c0a192f488..3c2cbcd55f5 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(54); + expect(registered).toHaveLength(56); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 6fa40f10441..30d39929e4c 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -299,6 +299,18 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'DELETE /session/:id/pending-prompts/:promptId', }, + { + method: 'GET', + path: '/session/:id/turns/current', + attribution: 'handler_resolved', + route: 'GET /session/:id/turns/current', + }, + { + method: 'GET', + path: '/session/:id/turns/:promptId', + attribution: 'handler_resolved', + route: 'GET /session/:id/turns/:promptId', + }, { method: 'POST', path: '/session/:id/shell', diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 26f8d7d63ec..a807fa4c29b 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -12,8 +12,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { ChatRecordingService, + isTurnResultRecordPayload, + normalizeTurnResultError, + TURN_RESULT_ERROR_CODE_MAX_CHARS, + TURN_RESULT_ERROR_MESSAGE_MAX_CHARS, type ChatRecord, type AtCommandRecordPayload, + type TurnResultRecordPayload, } from './chatRecordingService.js'; import { MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS } from '../utils/toolResultDisplayCompaction.js'; import * as jsonl from '../utils/jsonl-utils.js'; @@ -865,6 +870,96 @@ describe('ChatRecordingService', () => { }); }); + describe('recordTurnResult', () => { + it('normalizes hostile and oversized error fields without throwing', () => { + const hostile = Object.create(null, { + message: { get: () => 'm'.repeat(5_000) }, + code: { + get: () => 'c'.repeat(500), + }, + }); + + expect(normalizeTurnResultError(hostile)).toEqual({ + message: 'm'.repeat(TURN_RESULT_ERROR_MESSAGE_MAX_CHARS), + messageTruncated: true, + code: 'c'.repeat(TURN_RESULT_ERROR_CODE_MAX_CHARS), + codeTruncated: true, + }); + expect( + normalizeTurnResultError( + Object.create(null, { + message: { + get: () => { + throw new Error('getter exploded'); + }, + }, + toString: { + value: () => { + throw new Error('conversion exploded'); + }, + }, + }), + ), + ).toEqual({ message: 'Unknown error' }); + }); + + it('validates the bounded turn_result transcript contract', () => { + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + resultText: 'bounded prefix', + resultTruncated: true, + resultCode: 'RESULT_TEXT_TRUNCATED', + }), + ).toBe(true); + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + resultCode: 'RESULT_TEXT_TRUNCATED', + }), + ).toBe(false); + }); + + it('records a settled turn outcome as a system payload', async () => { + const payload: TurnResultRecordPayload = { + promptId: 'prompt-1', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'hello', + resultText: 'world', + originatorClientId: 'client-1', + }; + + chatRecordingService.recordTurnResult(payload); + await chatRecordingService.flush(); + + expect(jsonl.writeLine).toHaveBeenCalledTimes(1); + const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + expect(record.type).toBe('system'); + expect(record.subtype).toBe('turn_result'); + expect(record.systemPayload).toEqual(payload); + }); + + it('is best-effort when recording is inactive', () => { + const inactive = new ChatRecordingService(mockConfig); + expect(() => + inactive.recordTurnResult({ + promptId: 'prompt-1', + state: 'cancelled', + startedAt: 1000, + endedAt: 1500, + }), + ).not.toThrow(); + expect(jsonl.writeLine).not.toHaveBeenCalled(); + }); + }); + describe('recordAtCommand', () => { it('should record @-command metadata as a system payload', async () => { const userParts: Part[] = [{ text: 'Hello, world!' }]; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 613f7fd9e8a..4879cfa782f 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -300,7 +300,8 @@ export interface ChatRecord { | 'session_artifact_snapshot' | 'goal_state' | 'goal_runtime' - | 'realtime_message'; + | 'realtime_message' + | 'turn_result'; /** Explicit source classification used by Goal evidence validation. */ provenance?: ChatRecordProvenance; /** Goal identity and logical turn that owned this model-facing record. */ @@ -358,7 +359,8 @@ export interface ChatRecord { | UserTextElementsRecordPayload | SessionArtifactEventRecordPayload | SessionArtifactSnapshotRecordPayload - | GoalStateRecordPayloadV2; + | GoalStateRecordPayloadV2 + | TurnResultRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -565,6 +567,178 @@ export interface UserTextElementsRecordPayload { textElements: unknown[]; } +/** + * Cap (in UTF-16 code units) on the prompt / result text stored in a + * `turn_result` record. Writers truncate and set the paired flag. + */ +export const TURN_RESULT_TEXT_MAX_CHARS = 32_768; +export const TURN_RESULT_ERROR_MESSAGE_MAX_CHARS = 4_096; +export const TURN_RESULT_ERROR_CODE_MAX_CHARS = 256; + +export const TURN_RESULT_CODE_TEXT_TRUNCATED = 'RESULT_TEXT_TRUNCATED' as const; +export type TurnResultCode = typeof TURN_RESULT_CODE_TEXT_TRUNCATED; + +export interface TurnResultErrorPayload { + message: string; + code?: string; + messageTruncated?: boolean; + codeTruncated?: boolean; +} + +function readTurnResultErrorField( + error: unknown, + field: 'message' | 'code', +): unknown { + if ( + (typeof error !== 'object' || error === null) && + typeof error !== 'function' + ) { + return undefined; + } + try { + return Reflect.get(error, field); + } catch { + return undefined; + } +} + +function truncateTurnResultErrorField( + value: string, + maxChars: number, +): { value: string; truncated: boolean } { + return value.length > maxChars + ? { value: value.slice(0, maxChars), truncated: true } + : { value, truncated: false }; +} + +export function normalizeTurnResultError( + error: unknown, +): TurnResultErrorPayload { + const rawMessage = readTurnResultErrorField(error, 'message'); + let message = + typeof rawMessage === 'string' && rawMessage.length > 0 + ? rawMessage + : undefined; + if (message === undefined) { + try { + const converted = String(error); + if (converted.length > 0) message = converted; + } catch { + // Use the stable fallback below. + } + } + const boundedMessage = truncateTurnResultErrorField( + message ?? 'Unknown error', + TURN_RESULT_ERROR_MESSAGE_MAX_CHARS, + ); + + const rawCode = readTurnResultErrorField(error, 'code'); + const code = + typeof rawCode === 'string' && rawCode.length > 0 + ? rawCode + : typeof rawCode === 'number' + ? String(rawCode) + : undefined; + const boundedCode = + code === undefined + ? undefined + : truncateTurnResultErrorField(code, TURN_RESULT_ERROR_CODE_MAX_CHARS); + + return { + message: boundedMessage.value, + ...(boundedMessage.truncated ? { messageTruncated: true } : {}), + ...(boundedCode ? { code: boundedCode.value } : {}), + ...(boundedCode?.truncated ? { codeTruncated: true } : {}), + }; +} + +/** + * Settled outcome of one admitted prompt, appended at turn settle so + * pollable turn-status queries survive daemon restarts. `state` + * distinguishes normal completion (`completed`, with `stopReason`), + * user/abort cancellation (`cancelled`), and failure (`error`). + */ +export interface TurnResultRecordPayload { + promptId: string; + state: 'completed' | 'cancelled' | 'error'; + stopReason?: string; + error?: TurnResultErrorPayload; + /** Epoch ms the turn started executing (agent clock). */ + startedAt?: number; + /** Epoch ms the turn settled (agent clock). */ + endedAt: number; + promptText?: string; + promptTextTruncated?: boolean; + resultText?: string; + resultTruncated?: boolean; + resultCode?: TurnResultCode; + originatorClientId?: string; +} + +export function isTurnResultRecordPayload( + value: unknown, +): value is TurnResultRecordPayload { + if (typeof value !== 'object' || value === null) return false; + const payload = value as Record; + if ( + typeof payload['promptId'] !== 'string' || + payload['promptId'].length === 0 || + !['completed', 'cancelled', 'error'].includes(payload['state'] as string) || + typeof payload['endedAt'] !== 'number' || + !Number.isFinite(payload['endedAt']) + ) { + return false; + } + const optionalString = (field: string, maxChars?: number) => { + const fieldValue = payload[field]; + return ( + fieldValue === undefined || + (typeof fieldValue === 'string' && + (maxChars === undefined || fieldValue.length <= maxChars)) + ); + }; + const optionalBoolean = (field: string) => + payload[field] === undefined || typeof payload[field] === 'boolean'; + const optionalTimestamp = (field: string) => + payload[field] === undefined || + (typeof payload[field] === 'number' && Number.isFinite(payload[field])); + if ( + !optionalString('stopReason') || + !optionalTimestamp('startedAt') || + !optionalString('promptText', TURN_RESULT_TEXT_MAX_CHARS) || + !optionalBoolean('promptTextTruncated') || + !optionalString('resultText', TURN_RESULT_TEXT_MAX_CHARS) || + !optionalBoolean('resultTruncated') || + !optionalString('originatorClientId') || + (payload['resultCode'] !== undefined && + (payload['resultCode'] !== TURN_RESULT_CODE_TEXT_TRUNCATED || + payload['resultTruncated'] !== true)) + ) { + return false; + } + const error = payload['error']; + if (error === undefined) return payload['state'] !== 'error'; + if ( + payload['state'] !== 'error' || + typeof error !== 'object' || + error === null + ) { + return false; + } + const fields = error as Record; + return ( + typeof fields['message'] === 'string' && + fields['message'].length <= TURN_RESULT_ERROR_MESSAGE_MAX_CHARS && + (fields['code'] === undefined || + (typeof fields['code'] === 'string' && + fields['code'].length <= TURN_RESULT_ERROR_CODE_MAX_CHARS)) && + (fields['messageTruncated'] === undefined || + typeof fields['messageTruncated'] === 'boolean') && + (fields['codeTruncated'] === undefined || + typeof fields['codeTruncated'] === 'boolean') + ); +} + export interface ChatRecordingFailureEvent { sessionId: string; error: Error; @@ -2167,6 +2341,25 @@ export class ChatRecordingService { await this.appendRecordStrict(record); } + /** + * Append the settled outcome of a turn. Best-effort by design: a + * recording failure must never break turn settlement, so this uses the + * non-strict append path (inactive/failed writers skip silently). + */ + recordTurnResult(payload: TurnResultRecordPayload): void { + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'turn_result', + systemPayload: payload, + }; + this.appendRecord(record); + } catch (error) { + debugLogger.error('Error recording turn result:', error); + } + } + private appendSerializedFileHistorySnapshotBatch( snapshots: SerializedFileHistorySnapshot[], ): void { diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 4f8cc5144fc..156f164fb8a 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3516,6 +3516,47 @@ describe('SessionService', () => { expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); }); + it('does not copy source turn_result identities into a fork', async () => { + const oldId = '31313131-3131-3131-3131-313131313131'; + const newId = '41414141-4141-4141-4141-414141414141'; + const { file, lines } = seedSession(oldId); + fs.writeFileSync( + file, + [ + ...lines, + { + uuid: 'turn-result-1', + parentUuid: 'u2', + sessionId: oldId, + type: 'system', + subtype: 'turn_result', + timestamp: '2026-04-22T00:00:02.000Z', + cwd: lines[0]!['cwd'], + version: 'test', + systemPayload: { + promptId: 'source-prompt-id', + state: 'completed', + endedAt: 2000, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n', + ); + + const result = await service.forkSession(oldId, newId); + const written = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + + expect(written).toHaveLength(2); + expect(written.some((record) => record.subtype === 'turn_result')).toBe( + false, + ); + }); + it('writes source metadata and drops the inherited title for sourced forks', async () => { const oldId = '10101010-1010-1010-1010-101010101010'; const newId = '20202020-2020-2020-2020-202020202020'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 0e0cd0e8cba..4c08427abd5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1810,6 +1810,7 @@ export class SessionService { record.type === 'system' && (record.subtype === 'parent_session' || record.subtype === 'session_source' || + record.subtype === 'turn_result' || (options.source && record.subtype === 'custom_title')) ), ); diff --git a/packages/core/src/utils/conversation-branches.test.ts b/packages/core/src/utils/conversation-branches.test.ts index dfc4f4b9b2c..03961d5d971 100644 --- a/packages/core/src/utils/conversation-branches.test.ts +++ b/packages/core/src/utils/conversation-branches.test.ts @@ -187,6 +187,7 @@ describe('inspectConversationBranches', () => { 'conversation-leaf', 'session_artifact_snapshot', ), + system('turn-result', 'conversation-leaf', 'turn_result'), ]; expect( @@ -201,6 +202,7 @@ describe('inspectConversationBranches', () => { 'custom_title', 'session_artifact_event', 'session_artifact_snapshot', + 'turn_result', ] as const; for (const subtype of subtypes) { diff --git a/packages/core/src/utils/conversation-branches.ts b/packages/core/src/utils/conversation-branches.ts index ee5bb93552f..b549bb18973 100644 --- a/packages/core/src/utils/conversation-branches.ts +++ b/packages/core/src/utils/conversation-branches.ts @@ -17,6 +17,7 @@ const NEUTRAL_TAIL_SUBTYPES = new Set([ 'custom_title', 'session_artifact_event', 'session_artifact_snapshot', + 'turn_result', ]); export type ConversationBranchClassification = diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index 7e167005d73..ce33622b4d2 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -127,6 +127,7 @@ const KNOWN_RECORD_SUBTYPES = new Set([ 'session_source', 'goal_state', 'goal_runtime', + 'turn_result', ...ARTIFACT_RECORD_SUBTYPES, ]); From 71f27acd2f43020103775b3920875fd98a6d1329 Mon Sep 17 00:00:00 2001 From: hit_aran Date: Thu, 13 Aug 2026 17:42:58 +0800 Subject: [PATCH 2/7] test(serve): update telemetry route count --- packages/cli/src/serve/server/telemetry.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 2cd66082597..d8404e64a6e 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -812,17 +812,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 54 unique routes with the audited 47/7 attribution split', () => { + it('contains 56 unique routes with the audited 49/7 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(54); - expect(new Set(keys).size).toBe(54); + expect(keys).toHaveLength(56); + expect(new Set(keys).size).toBe(56); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(47); + ).toHaveLength(49); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', From 599543fe8d174776a0f19fe89977bfe779be76ae Mon Sep 17 00:00:00 2001 From: hit_aran Date: Sat, 15 Aug 2026 17:07:43 +0800 Subject: [PATCH 3/7] fix(serve): prefer settled turn outcome over deadline error overlay When the prompt-deadline path latches an error terminal in the overlay and the child later settles and persists a non-error turn_result for the same promptId, the poll surface previously kept the overlay error while enriching it with the successful resultText, and flipped to completed only after overlay eviction or restart. Merge via mergeTerminalWithPersisted at the two enrich call sites so the persisted outcome supersedes a bridge-synthesized error terminal once it exists; the exactly-once turn_error event publication and FIFO release are unchanged. The different-promptId endedAt tie-break is intentionally untouched. --- docs/design/daemon-turn-status-endpoint.md | 2 +- docs/developers/qwen-serve-protocol.md | 5 +- packages/acp-bridge/src/bridge.test.ts | 141 +++++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 23 +++- 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/docs/design/daemon-turn-status-endpoint.md b/docs/design/daemon-turn-status-endpoint.md index f35d10cf7c4..13782c8c447 100644 --- a/docs/design/daemon-turn-status-endpoint.md +++ b/docs/design/daemon-turn-status-endpoint.md @@ -27,7 +27,7 @@ States are `idle`, `queued`, `running`, `completed`, `cancelled`, and `error`. ` ## Live and persisted sources -The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Removed queued entries become terminal and are no longer projected as queued. A removed running entry remains `running` until Session settles it, because cancellation is cooperative and no terminal outcome exists yet. Entries with an already-published terminal are never projected as queued/running. Polling re-reads live state and the overlay after an awaited child read, including when that read fails, so a concurrent state change cannot regress to stale data. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`. +The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Removed queued entries become terminal and are no longer projected as queued. A removed running entry remains `running` until Session settles it, because cancellation is cooperative and no terminal outcome exists yet. Entries with an already-published terminal are never projected as queued/running. Polling re-reads live state and the overlay after an awaited child read, including when that read fails, so a concurrent state change cannot regress to stale data. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`, except when the overlay carries an error while the transcript records a settled non-error outcome for the same prompt: the transcript outcome then supersedes on the poll surface. This covers the deadline path, where the bridge latches `prompt_deadline_exceeded` while the agent keeps running and can still settle afterwards. Session is the only transcript writer. A daemon prompt that reaches `Session.prompt()` appends one best-effort `turn_result` system record through `ChatRecordingService`. The record stays on the active transcript chain so earlier bounded results remain queryable after later turns; forks omit it and reconnect any attached artifact record to its retained parent. Recording failure never changes the prompt lifecycle. Reads best-effort flush the recorder and walk at most 10 backward pages of 500 active records, with the existing 4 MiB page and snapshot limits. A single very large turn can consume that window, so an earlier result can return bounded not-found even when it remains in the JSONL. Invalid cursor, unavailable snapshot, oversized snapshot, and oversized page errors remain structured errors rather than becoming not-found. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index b5d9b6521ec..138fc9791e2 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2470,7 +2470,10 @@ If the HTTP client disconnects mid-prompt, the daemon sends an ACP `cancel` noti When `prompt_absolute_deadline` is advertised, `deadlineMs` may shorten the configured server deadline. Expiry emits a correlated `turn_error` with -`errorKind: "prompt_deadline_exceeded"`. +`errorKind: "prompt_deadline_exceeded"`. The deadline releases the caller +without killing the agent; if the agent later settles, turn-status polls for +that `promptId` return the settled transcript outcome instead of the deadline +error. ### `POST /session/:id/cancel` diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 9a5e5caed3a..19f52e3b1bc 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -15148,6 +15148,147 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('prefers the settled transcript outcome over a deadline error overlay', async () => { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + cancelImpl: () => new Promise(() => {}), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-deadline-settled', + state: 'completed', + stopReason: 'end_turn', + endedAt: 1, + resultText: 'late but real answer', + }, + } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const p1 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'wedge' }], + }, + undefined, + { promptId: 'prompt-deadline-settled', deadlineMs: 50 }, + ); + await expect(p1).rejects.toBeInstanceOf(PromptDeadlineExceededError); + + // The child settled after the deadline latch: both poll shapes return + // the persisted outcome, never the error overlay enriched with a + // successful resultText. + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-deadline-settled', + ), + ).resolves.toMatchObject({ + state: 'completed', + stopReason: 'end_turn', + resultText: 'late but real answer', + }); + await expect( + bridge.getSessionTurnStatus(session.sessionId), + ).resolves.toMatchObject({ + state: 'completed', + promptId: 'prompt-deadline-settled', + resultText: 'late but real answer', + }); + await bridge.shutdown(); + }); + + it('keeps the deadline error poll until the child settles', async () => { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + cancelImpl: () => new Promise(() => {}), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { v: 1, sessionId: 'ignored', turnResult: null } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const p1 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'wedge' }], + }, + undefined, + { promptId: 'prompt-deadline-pending', deadlineMs: 50 }, + ); + await expect(p1).rejects.toBeInstanceOf(PromptDeadlineExceededError); + + // No persisted record yet: the overlay deadline error stands, and it + // must not be enriched with any resultText. + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-deadline-pending', + ); + expect(status).toMatchObject({ + state: 'error', + promptId: 'prompt-deadline-pending', + }); + expect(status?.error).toMatchObject({ + code: 'prompt_deadline_exceeded', + }); + expect(status?.resultText).toBeUndefined(); + await bridge.shutdown(); + }); + + it('keeps the overlay error when the persisted record is also an error', async () => { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + cancelImpl: () => new Promise(() => {}), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-both-error', + state: 'error', + error: { message: 'child exploded' }, + endedAt: 1, + }, + } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const p1 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'wedge' }], + }, + undefined, + { promptId: 'prompt-both-error', deadlineMs: 50 }, + ); + await expect(p1).rejects.toBeInstanceOf(PromptDeadlineExceededError); + + // Both sources disagree on the error text only; the caller-facing + // deadline terminal stays authoritative. + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-both-error', + ); + expect(status).toMatchObject({ state: 'error' }); + expect(status?.error).toMatchObject({ + code: 'prompt_deadline_exceeded', + }); + await bridge.shutdown(); + }); + it('keeps the trusted prompt display projection after persistence', async () => { const handle = makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }), diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 4111f3a5094..f6bdfed8051 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2128,6 +2128,25 @@ function enrichTerminalTurnStatus( }; } +/** + * Merge an overlay terminal with the child's persisted record for the same + * prompt. A bridge-synthesized error terminal (prompt deadline, teardown + * flush) is superseded on the poll surface once the child has settled and + * persisted a non-error outcome: the deadline releases the caller without + * killing the agent, so the persisted outcome is what actually happened. + * Every other combination keeps the overlay outcome and enriches it with + * persisted text. + */ +function mergeTerminalWithPersisted( + terminal: BridgeTurnStatus, + persisted: BridgeTurnStatus, +): BridgeTurnStatus { + if (terminal.state === 'error' && persisted.state !== 'error') { + return persisted; + } + return enrichTerminalTurnStatus(terminal, persisted); +} + function latestTerminalTurnStatus( entry: SessionEntry, ): BridgeTurnStatus | undefined { @@ -10163,13 +10182,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : undefined; if (promptId !== undefined) { if (terminal && persisted) { - return enrichTerminalTurnStatus(terminal, persisted); + return mergeTerminalWithPersisted(terminal, persisted); } if (terminal) return terminal; if (persisted) return persisted; } else { if (terminal && persisted && terminal.promptId === persisted.promptId) { - return enrichTerminalTurnStatus(terminal, persisted); + return mergeTerminalWithPersisted(terminal, persisted); } if (terminal && persisted) { return (terminal.endedAt ?? 0) >= (persisted.endedAt ?? 0) From 67a4cb8db9892a23bfb9297935c6c459558913fc Mon Sep 17 00:00:00 2001 From: hit_aran Date: Sat, 15 Aug 2026 21:35:54 +0800 Subject: [PATCH 4/7] fix(serve): pin turn-start session identity for turn-result settle R4-1: settle resolved the ChatRecordingService at settle time, so a startNewSession rotation mid-turn could land the turn_result record in the new session's transcript while the poll surface kept reading the old one. Capture the recorder at turn start and settle on that instance; pin the outgoing service's session identity at rotation so the late append keeps the pre-rotation sessionId. R4-2: reject empty error.message/error.code in turn_result payloads, mirroring the existing empty-promptId rejection. Adds rotation/pin regression tests plus the round-4 test suggestions (extractor fallback, startedAt, cancel/error race matrix, resultCode defaulting, removed-prompt projections). --- packages/acp-bridge/src/bridge.test.ts | 140 +++++++++++++++++- .../acp-integration/session/Session.test.ts | 130 +++++++++++++++- .../src/acp-integration/session/Session.ts | 12 +- packages/core/src/config/config.test.ts | 25 ++++ packages/core/src/config/config.ts | 4 + .../src/services/chatRecordingService.test.ts | 92 ++++++++++++ .../core/src/services/chatRecordingService.ts | 23 ++- 7 files changed, 422 insertions(+), 4 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index fcae6fc847c..fe8bb3578ea 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -99,6 +99,7 @@ import { ShellExecutionService, stableSessionArtifactId, ToolNames, + TURN_RESULT_CODE_TEXT_TRUNCATED, TURN_RESULT_TEXT_MAX_CHARS, } from '@qwen-code/qwen-code-core'; import { @@ -15103,6 +15104,7 @@ describe('createAcpSessionBridge', () => { }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); let admittedStatus: Promise | undefined; + let admittedPromptCallCount: number | undefined; const prompt = bridge.sendPrompt( session.sessionId, @@ -15114,7 +15116,7 @@ describe('createAcpSessionBridge', () => { { promptId: 'prompt-dispatch-boundary', onPromptAdmitted: () => { - expect(handle.agent.promptCalls).toHaveLength(0); + admittedPromptCallCount = handle.agent.promptCalls.length; admittedStatus = bridge.getSessionTurnStatus( session.sessionId, undefined, @@ -15129,6 +15131,10 @@ describe('createAcpSessionBridge', () => { promptId: 'prompt-dispatch-boundary', }); await expect(admittedStatus).resolves.not.toHaveProperty('startedAt'); + // Asserted outside the callback: the bridge swallows exceptions thrown + // inside onPromptAdmitted, so an in-callback assertion would surface as + // a misleading async-contract failure instead of an ordering one. + expect(admittedPromptCallCount).toBe(0); await prompt; await bridge.shutdown(); @@ -15225,6 +15231,16 @@ describe('createAcpSessionBridge', () => { resolveTurn!(); await promptPromise; + + const settled = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-long', + ); + expect(settled?.state).toBe('completed'); + expect(settled?.promptText).toHaveLength(TURN_RESULT_TEXT_MAX_CHARS); + expect(settled?.promptTextTruncated).toBe(true); + await bridge.shutdown(); }); @@ -15359,6 +15375,54 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('defaults resultCode for truncated persisted records missing it', async () => { + const turnResult = { + promptId: 'prompt-truncated-legacy', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'settled prompt', + resultText: 'settled answer', + resultTruncated: true, + }; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + return { v: 1, sessionId: 'ignored', turnResult }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settled prompt' }], + }, + undefined, + { promptId: 'prompt-truncated-legacy' }, + ); + + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-truncated-legacy', + ), + ).resolves.toMatchObject({ + state: 'completed', + resultTruncated: true, + resultCode: TURN_RESULT_CODE_TEXT_TRUNCATED, + }); + await bridge.shutdown(); + }); + it('resolves undefined for unknown promptId and idle for empty current', async () => { const handle = makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }), @@ -15724,6 +15788,80 @@ describe('createAcpSessionBridge', () => { turn.resolve({ stopReason: 'end_turn' }); await prompt; + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-running-remove', + ), + ).resolves.toMatchObject({ state: 'completed', stopReason: 'end_turn' }); + await bridge.shutdown(); + }); + + it('projects a removed queued prompt as cancelled instead of queued', async () => { + let resolveFirst: (() => void) | undefined; + const firstGate = new Promise((r) => { + resolveFirst = r; + }); + const handle = makeChannel({ + promptImpl: async (req: PromptRequest) => { + if ((req.prompt[0] as { text?: string }).text === 'blocking') { + await firstGate; + } + return { stopReason: 'end_turn' } as PromptResponse; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const runningPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'blocking' }], + }, + undefined, + { promptId: 'prompt-blocking' }, + ); + const queuedPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'queued then removed' }], + }, + undefined, + { promptId: 'prompt-queued-remove' }, + ); + + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(2); + }); + + expect( + bridge.removePendingPrompt(session.sessionId, 'prompt-queued-remove'), + ).toEqual({ removed: true }); + + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-queued-remove', + ), + ).resolves.toMatchObject({ + state: 'cancelled', + promptId: 'prompt-queued-remove', + }); + await expect( + bridge.getSessionTurnStatus(session.sessionId), + ).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-blocking', + }); + + resolveFirst!(); + await runningPromise; + await expect(queuedPromise).rejects.toBeDefined(); await bridge.shutdown(); }); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 6f603a9d9ab..982f0214f67 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4533,6 +4533,39 @@ describe('Session', () => { expect(payload.startedAt).toBeLessThanOrEqual(payload.endedAt); }); + it('keeps the settle append on the turn-start recorder across a mid-turn rotation', async () => { + const rotatedRecordingService: typeof mockChatRecordingService = { + ...mockChatRecordingService, + recordTurnResult: vi.fn(), + }; + let rotated = false; + vi.mocked(mockConfig.getChatRecordingService).mockImplementation( + () => + (rotated + ? rotatedRecordingService + : mockChatRecordingService) as unknown as core.ChatRecordingService, + ); + mockChat.sendMessageStream = vi.fn().mockImplementation(() => { + // Simulates a session rotation landing between admission and + // settle (e.g. `/clear` dispatched as daemon prompt text). + rotated = true; + return Promise.resolve(createEmptyStream()); + }); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'trusted prompt' }], + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 1, + ); + expect(rotatedRecordingService.recordTurnResult).not.toHaveBeenCalled(); + }); + it('records daemon display text instead of internal prompt context', async () => { mockChat.sendMessageStream = vi .fn() @@ -4592,6 +4625,46 @@ describe('Session', () => { ); }); + it('falls back to the prompt extractor when no display text meta is present', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [ + { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, + ], + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: '[image]', + }), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: '' }], + }, + trustedContext, + ); + + expect( + mockChatRecordingService.recordTurnResult, + ).toHaveBeenLastCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: '', + }), + ); + }); + it('accumulates streamed agent text into resultText', async () => { mockChat.sendMessageStream = vi.fn().mockResolvedValue( createStreamWithChunks([ @@ -4829,9 +4902,38 @@ describe('Session', () => { error: { message: 'model exploded' }, }), ); + const errorPayload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(typeof errorPayload.startedAt).toBe('number'); + expect(errorPayload.startedAt!).toBeLessThanOrEqual( + errorPayload.endedAt!, + ); + }); + + it('records an error turn for an abort-shaped stream failure without a user cancel', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createFailingStream('Request was aborted.')); + + await expect( + session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'abort shaped' }], + }, + trustedContext, + ), + ).rejects.toThrow('Request was aborted.'); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + state: 'error', + }), + ); }); - it('records a cancelled turn when user cancel races a non-abort stream error', async () => { + it('records a cancelled turn when user cancel races an abort-shaped stream error', async () => { mockChat.sendMessageStream = vi.fn().mockResolvedValue( createFailingStream('Request was aborted.', () => { void session.cancelPendingPrompt(); @@ -4856,6 +4958,32 @@ describe('Session', () => { ); }); + it('records a cancelled turn when user cancel races a plain stream error', async () => { + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createFailingStream('model exploded', () => { + void session.cancelPendingPrompt(); + }), + ); + + await expect( + session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'cancel me' }], + }, + trustedContext, + ), + ).resolves.toEqual({ stopReason: 'cancelled' }); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + state: 'cancelled', + stopReason: 'cancelled', + }), + ); + }); + it('does not record a turn_result without an invocation context', async () => { mockChat.sendMessageStream = vi .fn() diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 31acf024148..9cbdd5480b8 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -49,6 +49,7 @@ import type { MemoryWriteCandidate, CronTaskDelivery, InvocationContextV1, + ChatRecordingService, TurnResultRecordPayload, WorkflowApproval, BranchPoint, @@ -1026,6 +1027,13 @@ interface InFlightTurnRecording { promptText: string; promptTextTruncated: boolean; finalAnswer: { finalText: string }; + /** + * Captured at turn start; settle writes against this instance (pinned to + * the turn-start session by `Config.startNewSession`) instead of + * re-resolving, so a mid-turn session rotation cannot redirect this turn's + * `turn_result` into the new session's transcript. + */ + recordingService?: ChatRecordingService; } function truncateTurnText(text: string): { @@ -6003,6 +6011,7 @@ export class Session implements SessionContext { const { text, truncated } = truncateTurnText( promptDisplayText ?? extractTurnPromptText(params.prompt), ); + const recordingService = this.config.getChatRecordingService(); return { promptId: invocationContext.promptId, ...(invocationContext.originatorClientId !== undefined @@ -6011,6 +6020,7 @@ export class Session implements SessionContext { promptText: text, promptTextTruncated: truncated, finalAnswer: { finalText: '' }, + ...(recordingService !== undefined ? { recordingService } : {}), }; } @@ -6047,7 +6057,7 @@ export class Session implements SessionContext { : {}), }; try { - this.config.getChatRecordingService()?.recordTurnResult(payload); + recording.recordingService?.recordTurnResult(payload); } catch (recordError) { debugLogger.warn( `Failed to record turn result: ${this.#formatError(recordError)}`, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index cc95c2d53e1..529af74753b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2261,6 +2261,26 @@ describe('Server Config (config.ts)', () => { ); }); + it('pins the outgoing chat recorder to the outgoing session id', async () => { + const config = new Config({ ...baseParams, chatRecording: true }); + await config.initialize({ + skipGeminiInitialization: true, + skipHooks: true, + skipMcpDiscovery: true, + skipSkillManager: true, + skipFileCheckpointing: true, + }); + const outgoingSessionId = config.getSessionId(); + const outgoingRecorder = config.getChatRecordingService(); + expect(outgoingRecorder).toBeDefined(); + const pinSpy = vi.spyOn(outgoingRecorder!, 'pinSessionIdentity'); + + config.startNewSession('replacement-session'); + + expect(pinSpy).toHaveBeenCalledWith(outgoingSessionId); + expect(config.getChatRecordingService()).not.toBe(outgoingRecorder); + }); + it('ends the outgoing session before starting a replacement without continuation', async () => { const config = new Config({ ...baseParams }); await config.initialize({ @@ -2712,24 +2732,29 @@ describe('Server Config (config.ts)', () => { }); const finalize = vi.fn(); const flush = vi.fn().mockResolvedValue(undefined); + const pinSessionIdentity = vi.fn(); ( config as unknown as { chatRecordingService?: { finalize: () => void; flush: () => Promise; hasWriteOwnership: () => boolean; + pinSessionIdentity: (sessionId: string) => void; }; } ).chatRecordingService = { finalize, flush, hasWriteOwnership: () => false, + pinSessionIdentity, }; + const outgoingSessionId = config.getSessionId(); config.startNewSession(); expect(finalize).toHaveBeenCalledTimes(1); expect(flush).toHaveBeenCalledTimes(1); + expect(pinSessionIdentity).toHaveBeenCalledWith(outgoingSessionId); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9698a8e478a..d3e1b098284 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3995,6 +3995,10 @@ export class Config { this.getOwnActiveTodoReminderTurns().clear(); setDebugLogSession(this); this.debugLogger = createDebugLogger(); + // Pin the outgoing recorder to the session it wrote so late writes (a + // turn settling after this rotation) keep targeting that session's + // transcript instead of resolving the new session id from this Config. + outgoingChatRecordingService?.pinSessionIdentity(previousSessionId); this.chatRecordingService = this.chatRecordingEnabled ? this.createChatRecordingService() : undefined; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index ee2620967e7..d47b7bab9ac 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1323,6 +1323,33 @@ describe('ChatRecordingService', () => { ).toBe(false); }); + it('rejects empty error message and code in turn_result payloads', () => { + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'error', + endedAt: 2_000, + error: { message: '' }, + }), + ).toBe(false); + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'error', + endedAt: 2_000, + error: { message: 'boom', code: '' }, + }), + ).toBe(false); + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'error', + endedAt: 2_000, + error: { message: 'boom' }, + }), + ).toBe(true); + }); + it('records a settled turn outcome as a system payload', async () => { const payload: TurnResultRecordPayload = { promptId: 'prompt-1', @@ -1387,6 +1414,71 @@ describe('ChatRecordingService', () => { ).not.toThrow(); expect(jsonl.writeLine).not.toHaveBeenCalled(); }); + + describe('session identity pinning', () => { + it('keeps late turn_result writes on the pinned pre-rotation session', async () => { + const outgoing = new ChatRecordingService(mockConfig, undefined, false); + outgoing.pinSessionIdentity('test-session-id'); + vi.mocked(mockConfig.getSessionId).mockReturnValue( + 'rotated-session-id', + ); + + outgoing.recordTurnResult({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + }); + await outgoing.flush(); + + expect(jsonl.writeLine).toHaveBeenCalledTimes(1); + const [filePath, record] = vi.mocked(jsonl.writeLine).mock.calls[0] as [ + string, + ChatRecord, + ]; + expect(filePath).toContain('test-session-id.jsonl'); + expect(record.sessionId).toBe('test-session-id'); + }); + + it('resolves the shared Config session id at write time when not pinned', async () => { + const outgoing = new ChatRecordingService(mockConfig, undefined, false); + vi.mocked(mockConfig.getSessionId).mockReturnValue( + 'rotated-session-id', + ); + + outgoing.recordTurnResult({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + }); + await outgoing.flush(); + + const [filePath, record] = vi.mocked(jsonl.writeLine).mock.calls[0] as [ + string, + ChatRecord, + ]; + expect(filePath).toContain('rotated-session-id.jsonl'); + expect(record.sessionId).toBe('rotated-session-id'); + }); + + it('never overrides a lease binding that owns the session identity', async () => { + chatRecordingService.pinSessionIdentity('pinned-session-id'); + vi.mocked(mockConfig.getSessionId).mockReturnValue( + 'rotated-session-id', + ); + + chatRecordingService.recordTurnResult({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + }); + await chatRecordingService.flush(); + + const record = vi + .mocked(jsonl.writeLine) + .mock.calls.at(-1)![1] as ChatRecord; + expect(record.sessionId).toBe('test-session-id'); + }); + }); }); describe('recordAtCommand', () => { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b91523079e6..69451b2ddbc 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -747,9 +747,11 @@ export function isTurnResultRecordPayload( const fields = error as Record; return ( typeof fields['message'] === 'string' && + fields['message'].length > 0 && fields['message'].length <= TURN_RESULT_ERROR_MESSAGE_MAX_CHARS && (fields['code'] === undefined || (typeof fields['code'] === 'string' && + fields['code'].length > 0 && fields['code'].length <= TURN_RESULT_ERROR_CODE_MAX_CHARS)) && (fields['messageTruncated'] === undefined || typeof fields['messageTruncated'] === 'boolean') && @@ -844,6 +846,8 @@ export class ChatRecordingService { private turnParentUuids: Array = []; private chatsDirEnsured = false; private cachedConversationFile: string | undefined; + /** Session identity pinned by `pinSessionIdentity` at rotation time. */ + private pinnedSessionId: string | undefined; private state: | 'inactive' | 'active' @@ -1006,7 +1010,11 @@ export class ChatRecordingService { * @returns The session ID. */ private getSessionId(): string { - return this.binding?.sessionId ?? this.config.getSessionId(); + return ( + this.binding?.sessionId ?? + this.pinnedSessionId ?? + this.config.getSessionId() + ); } private ensureChatsDir(): string { @@ -1584,6 +1592,19 @@ export class ChatRecordingService { return this.binding !== undefined; } + /** + * Pins this recorder to the given session identity so late writes keep + * targeting that session's transcript even after `Config.startNewSession()` + * rotates the shared Config to a new session id. Called on the outgoing + * recorder at rotation time; a lease binding already owns the identity and + * is never overridden. + */ + pinSessionIdentity(sessionId: string): void { + if (this.binding === undefined) { + this.pinnedSessionId = sessionId; + } + } + getTranscriptCursor(): TranscriptCursor { return { recordId: this.lastRecordUuid }; } From 82d689b60b8b880acca35282cbd8bcc799ee6ce0 Mon Sep 17 00:00:00 2001 From: hit_aran Date: Mon, 17 Aug 2026 11:03:05 +0800 Subject: [PATCH 5/7] fix(serve): enforce the turn_result bounded contract on the write path R3-3: cap promptId, stopReason, and originatorClientId at 256 chars in isTurnResultRecordPayload, closing the unbounded echo of corrupted-transcript values through GET /session/:id/turns/:promptId; recordTurnResult now validates payloads against the same contract before appending, so type-correct but invalid shapes (error state without error, error on non-error states) can no longer produce records invisible to the restart scan. Also lands the four round-5 test assertions: merged-payload error-leak pin, multi-model-call settle count, successor attribution in the superseded-throws test, and the early session-mismatch guard pin. --- packages/acp-bridge/src/bridge.test.ts | 14 ++--- .../acp-integration/session/Session.test.ts | 15 +++++ .../src/services/chatRecordingService.test.ts | 60 +++++++++++++++++++ .../core/src/services/chatRecordingService.ts | 13 +++- 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index ec47ae0822f..1fbc7262c20 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -16107,17 +16107,17 @@ describe('createAcpSessionBridge', () => { { promptId: 'prompt-enriched' }, ); - await expect( - bridge.getSessionTurnStatus( - session.sessionId, - undefined, - 'prompt-enriched', - ), - ).resolves.toMatchObject({ + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-enriched', + ); + expect(status).toMatchObject({ state: 'completed', stopReason: 'end_turn', resultText: 'final parent answer', }); + expect(status).not.toHaveProperty('error'); await bridge.shutdown(); }); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index f2d3d4c9c6a..298e0af1d6c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4759,6 +4759,9 @@ describe('Session', () => { trustedContext, ); + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 1, + ); expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( expect.objectContaining({ resultText: 'The final answer is 42.' }), ); @@ -5164,6 +5167,17 @@ describe('Session', () => { stopReason: 'cancelled', promptText: 'first prompt', }); + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledTimes( + 2, + ); + const secondPayload = + mockChatRecordingService.recordTurnResult.mock.calls + .map((call) => call[0]) + .find((payload) => payload.promptId === 'second-prompt-id'); + expect(secondPayload).toMatchObject({ + promptId: 'second-prompt-id', + state: 'completed', + }); }); it('records a cancelled turn when cancelled while waiting for the predecessor', async () => { @@ -5270,6 +5284,7 @@ describe('Session', () => { 'Invocation context session does not match the active session', ); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + expect(mockChatRecordingService.recordTurnResult).not.toHaveBeenCalled(); }); it('does not create invocation context for standalone ACP prompts', async () => { diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index d47b7bab9ac..4af6fada690 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -15,6 +15,7 @@ import { isTurnResultRecordPayload, normalizeTurnResultError, TURN_RESULT_ERROR_CODE_MAX_CHARS, + TURN_RESULT_IDENTIFIER_MAX_CHARS, TURN_RESULT_ERROR_MESSAGE_MAX_CHARS, type ChatRecord, type AtCommandRecordPayload, @@ -1323,6 +1324,43 @@ describe('ChatRecordingService', () => { ).toBe(false); }); + it('caps promptId, stopReason, and originatorClientId in turn_result payloads', () => { + const oversized = 'x'.repeat(TURN_RESULT_IDENTIFIER_MAX_CHARS + 1); + expect( + isTurnResultRecordPayload({ + promptId: oversized, + state: 'completed', + endedAt: 2_000, + }), + ).toBe(false); + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + stopReason: oversized, + }), + ).toBe(false); + expect( + isTurnResultRecordPayload({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + originatorClientId: oversized, + }), + ).toBe(false); + const bounded = 'y'.repeat(TURN_RESULT_IDENTIFIER_MAX_CHARS); + expect( + isTurnResultRecordPayload({ + promptId: bounded, + state: 'completed', + endedAt: 2_000, + stopReason: bounded, + originatorClientId: bounded, + }), + ).toBe(true); + }); + it('rejects empty error message and code in turn_result payloads', () => { expect( isTurnResultRecordPayload({ @@ -1372,6 +1410,28 @@ describe('ChatRecordingService', () => { expect(record.systemPayload).toEqual(payload); }); + it('refuses to append payloads the bounded contract rejects', async () => { + chatRecordingService.recordTurnResult({ + promptId: 'prompt-1', + state: 'error', + endedAt: 2_000, + }); + chatRecordingService.recordTurnResult({ + promptId: 'prompt-2', + state: 'completed', + endedAt: 2_000, + error: { message: 'stray' }, + }); + await chatRecordingService.flush(); + + const records = vi + .mocked(jsonl.writeLine) + .mock.calls.map((call) => call[1] as ChatRecord); + expect( + records.filter((record) => record.subtype === 'turn_result'), + ).toHaveLength(0); + }); + it('keeps turn_result records on the active transcript chain', async () => { chatRecordingService.recordUserMessage([{ text: 'before result' }]); chatRecordingService.recordTurnResult({ diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 69451b2ddbc..710df620c13 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -591,6 +591,7 @@ export interface UserTextElementsRecordPayload { export const TURN_RESULT_TEXT_MAX_CHARS = 32_768; export const TURN_RESULT_ERROR_MESSAGE_MAX_CHARS = 4_096; export const TURN_RESULT_ERROR_CODE_MAX_CHARS = 256; +export const TURN_RESULT_IDENTIFIER_MAX_CHARS = 256; export const TURN_RESULT_CODE_TEXT_TRUNCATED = 'RESULT_TEXT_TRUNCATED' as const; export type TurnResultCode = typeof TURN_RESULT_CODE_TEXT_TRUNCATED; @@ -702,6 +703,7 @@ export function isTurnResultRecordPayload( if ( typeof payload['promptId'] !== 'string' || payload['promptId'].length === 0 || + payload['promptId'].length > TURN_RESULT_IDENTIFIER_MAX_CHARS || !['completed', 'cancelled', 'error'].includes(payload['state'] as string) || typeof payload['endedAt'] !== 'number' || !Number.isFinite(payload['endedAt']) @@ -722,13 +724,13 @@ export function isTurnResultRecordPayload( payload[field] === undefined || (typeof payload[field] === 'number' && Number.isFinite(payload[field])); if ( - !optionalString('stopReason') || + !optionalString('stopReason', TURN_RESULT_IDENTIFIER_MAX_CHARS) || !optionalTimestamp('startedAt') || !optionalString('promptText', TURN_RESULT_TEXT_MAX_CHARS) || !optionalBoolean('promptTextTruncated') || !optionalString('resultText', TURN_RESULT_TEXT_MAX_CHARS) || !optionalBoolean('resultTruncated') || - !optionalString('originatorClientId') || + !optionalString('originatorClientId', TURN_RESULT_IDENTIFIER_MAX_CHARS) || (payload['resultCode'] !== undefined && (payload['resultCode'] !== TURN_RESULT_CODE_TEXT_TRUNCATED || payload['resultTruncated'] !== true)) @@ -2580,6 +2582,13 @@ export class ChatRecordingService { * non-strict append path (inactive/failed writers skip silently). */ recordTurnResult(payload: TurnResultRecordPayload): void { + if (!isTurnResultRecordPayload(payload)) { + debugLogger.error( + 'Skipping turn result record that violates the bounded contract:', + payload, + ); + return; + } try { const record: ChatRecord = { ...this.createBaseRecord('system'), From 0b5c336dac07147dedea9fb5d314ea61ad62d714 Mon Sep 17 00:00:00 2001 From: hit_aran Date: Mon, 17 Aug 2026 15:35:35 +0800 Subject: [PATCH 6/7] fix(serve): address round-6 review findings on daemon turn status - Session: settle a successor-aborted turn as cancelled only when the thrown error is the abort itself; genuine failures after a NEW_PROMPT abort surface as error, matching the send-loop contract - bridge: serve repeat polls of a settled promptId from the enriched overlay instead of re-scanning the child transcript, and give the turn-status read the transcript timeout instead of the 10s init default - bridge: forward the channel display text unchanged; Session treats an empty display text as absent for the turn record ([image] fallback) - Session: cap streamed-response accumulation for turns without a channel delivery at the turn-result bound - docs: document the bounded non-monotonicity of poll terminals --- docs/design/daemon-turn-status-endpoint.md | 4 +- packages/acp-bridge/src/bridge.test.ts | 140 +++++++++++- packages/acp-bridge/src/bridge.ts | 60 +++++- .../acp-integration/session/Session.test.ts | 199 +++++++++++++++++- .../src/acp-integration/session/Session.ts | 136 +++++++++--- 5 files changed, 498 insertions(+), 41 deletions(-) diff --git a/docs/design/daemon-turn-status-endpoint.md b/docs/design/daemon-turn-status-endpoint.md index 13782c8c447..fc72a8cb1af 100644 --- a/docs/design/daemon-turn-status-endpoint.md +++ b/docs/design/daemon-turn-status-endpoint.md @@ -27,7 +27,9 @@ States are `idle`, `queued`, `running`, `completed`, `cancelled`, and `error`. ` ## Live and persisted sources -The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Removed queued entries become terminal and are no longer projected as queued. A removed running entry remains `running` until Session settles it, because cancellation is cooperative and no terminal outcome exists yet. Entries with an already-published terminal are never projected as queued/running. Polling re-reads live state and the overlay after an awaited child read, including when that read fails, so a concurrent state change cannot regress to stale data. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`, except when the overlay carries an error while the transcript records a settled non-error outcome for the same prompt: the transcript outcome then supersedes on the poll surface. This covers the deadline path, where the bridge latches `prompt_deadline_exceeded` while the agent keeps running and can still settle afterwards. +The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Removed queued entries become terminal and are no longer projected as queued. A removed running entry remains `running` until Session settles it, because cancellation is cooperative and no terminal outcome exists yet. Entries with an already-published terminal are never projected as queued/running. Polling re-reads live state and the overlay after an awaited child read, including when that read fails, so a concurrent state change cannot regress to stale data. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`, except when the overlay carries an error while the transcript records a settled non-error outcome for the same prompt: the transcript outcome then supersedes on the poll surface. This covers the deadline path, where the bridge latches `prompt_deadline_exceeded` while the agent keeps running and can still settle afterwards. Once a poll has combined the overlay with the child's persisted record for a promptId — whether merged or persisted-only — that answer is written back into the overlay, and later polls for the same promptId are served from it without re-scanning the transcript. + +Terminal reporting is not monotonic across polls, by construction: both sources are bounded, and the persisted outcome supersedes a bridge-synthesized error. A deadline-exceeded prompt can therefore read `error` before settle and `completed` afterwards, and any settled result eventually leaves both the 64-entry overlay and the 10-page scan window, after which the exact route returns `404`. Field coverage can also narrow when only the persisted record remains (for example `queuedAt` is overlay-sourced). Clients should treat a backwards state transition or a `404` as bounded-window expiry rather than a new turn outcome. Session is the only transcript writer. A daemon prompt that reaches `Session.prompt()` appends one best-effort `turn_result` system record through `ChatRecordingService`. The record stays on the active transcript chain so earlier bounded results remain queryable after later turns; forks omit it and reconnect any attached artifact record to its retained parent. Recording failure never changes the prompt lifecycle. Reads best-effort flush the recorder and walk at most 10 backward pages of 500 active records, with the existing 4 MiB page and snapshot limits. A single very large turn can consume that window, so an earlier result can return bounded not-found even when it remains in the JSONL. Invalid cursor, unavailable snapshot, oversized snapshot, and oversized page errors remain structured errors rather than becoming not-found. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 1fbc7262c20..b2ad30dd41a 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -13180,9 +13180,11 @@ describe('createAcpSessionBridge', () => { { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, { type: 'resource_link', uri: 'file:///visible', name: 'visible' }, ]); + // The forwarded display text stays the worker's raw projection; the + // child's turn recording treats '' as absent and derives `[image]`. expect( handle.agent.promptCalls[0]?._meta?.['qwen.daemon.promptDisplayText'], - ).toBe('[image]'); + ).toBe(''); abort.abort(); await bridge.shutdown(); }); @@ -15587,6 +15589,142 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('serves repeated settled polls from the enriched overlay without rescanning', async () => { + const turnResult = { + promptId: 'prompt-cached', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'settled prompt', + resultText: 'settled answer', + }; + let turnStatusReads = 0; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + turnStatusReads += 1; + return { v: 1, sessionId: 'ignored', turnResult }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settled prompt' }], + }, + undefined, + { promptId: 'prompt-cached' }, + ); + + const first = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-cached', + ); + expect(first).toMatchObject({ + sessionId: session.sessionId, + state: 'completed', + promptId: 'prompt-cached', + resultText: 'settled answer', + }); + expect(turnStatusReads).toBe(1); + + const second = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-cached', + ); + expect(second).toEqual(first); + expect(turnStatusReads).toBe(1); + + await bridge.shutdown(); + }); + + it('keeps rescanning a non-enriched terminal until the transcript record is visible', async () => { + let turnResult: { + promptId: string; + state: string; + stopReason: string; + startedAt: number; + endedAt: number; + promptText: string; + resultText: string; + } | null = null; + let turnStatusReads = 0; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + turnStatusReads += 1; + return { v: 1, sessionId: 'ignored', turnResult }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'late prompt' }], + }, + undefined, + { promptId: 'prompt-late' }, + ); + + // First poll: the overlay terminal exists but the child transcript has + // no record yet — serve the terminal without caching it as enriched. + const first = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-late', + ); + expect(first).toMatchObject({ + sessionId: session.sessionId, + state: 'completed', + promptId: 'prompt-late', + }); + expect(first?.resultText).toBeUndefined(); + expect(turnStatusReads).toBe(1); + + // The transcript record becomes visible afterwards. + turnResult = { + promptId: 'prompt-late', + state: 'completed', + stopReason: 'end_turn', + startedAt: 1000, + endedAt: 2000, + promptText: 'late prompt', + resultText: 'late answer', + }; + + const second = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-late', + ); + expect(second).toMatchObject({ + sessionId: session.sessionId, + state: 'completed', + promptId: 'prompt-late', + resultText: 'late answer', + }); + expect(turnStatusReads).toBe(2); + + await bridge.shutdown(); + }); + it('defaults resultCode for truncated persisted records missing it', async () => { const turnResult = { promptId: 'prompt-truncated-legacy', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index c92fb88784b..ceb231e2e2f 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -990,6 +990,13 @@ interface SessionEntry { pendingPromptList: PendingPromptEntry[]; /** Recent formal terminals bridge-published before transcript visibility. */ terminalTurnStatuses: Map; + /** + * promptIds whose overlay terminal was enriched with the child's persisted + * `turn_result` by `getSessionTurnStatus`. Those entries fully answer a + * status poll, so they can be served without re-scanning the child + * transcript. + */ + enrichedTerminalPromptIds: Set; /** Bridge prompt that owns the child Guard wait for this FIFO. */ todoStopGuardAwaitingQueuedPromptOwnerPromptId?: string; /** @@ -1895,10 +1902,35 @@ function rememberTerminalTurnStatus( error: normalizeTurnResultError(terminal.err), }; entry.terminalTurnStatuses.set(pending.promptId, status); + // A fresh bridge-published terminal replaces any enriched answer for the + // same promptId. + entry.enrichedTerminalPromptIds.delete(pending.promptId); + while (entry.terminalTurnStatuses.size > TERMINAL_TURN_STATUS_OVERLAY_LIMIT) { + const oldest = entry.terminalTurnStatuses.keys().next().value; + if (oldest === undefined) break; + entry.terminalTurnStatuses.delete(oldest); + entry.enrichedTerminalPromptIds.delete(oldest); + } +} + +/** + * Write a `getSessionTurnStatus` answer back into the overlay so later polls + * for the same settled promptId are served from memory instead of forcing a + * full child transcript scan each time. Shares the overlay's bounded + * eviction. + */ +function rememberEnrichedTerminalTurnStatus( + entry: SessionEntry, + promptId: string, + status: BridgeTurnStatus, +): void { + entry.terminalTurnStatuses.set(promptId, status); + entry.enrichedTerminalPromptIds.add(promptId); while (entry.terminalTurnStatuses.size > TERMINAL_TURN_STATUS_OVERLAY_LIMIT) { const oldest = entry.terminalTurnStatuses.keys().next().value; if (oldest === undefined) break; entry.terminalTurnStatuses.delete(oldest); + entry.enrichedTerminalPromptIds.delete(oldest); } } @@ -5594,6 +5626,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingAgentNotificationCount: 0, pendingPromptList: [], terminalTurnStatuses: new Map(), + enrichedTerminalPromptIds: new Set(), midTurnMessageQueue: [], settledMidTurnMessageIds: [], promotedMidTurnMessageIds: [], @@ -8061,8 +8094,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { copy._meta && typeof copy._meta === 'object' ? { ...copy._meta } : {}; - const promptDisplayText = - channelDisplayText === undefined ? undefined : pendingText; + const promptDisplayText = channelDisplayText; delete meta[DAEMON_RETRY_META_KEY]; delete meta[INVOCATION_CONTEXT_META_KEY]; delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY]; @@ -10243,6 +10275,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const liveBeforeRead = findLiveTurnStatus(entry, promptId); if (liveBeforeRead) return liveBeforeRead; + // An overlay terminal a previous poll already enriched with the + // child's persisted record fully answers the query; serving it here + // spares settled prompts a full child transcript scan on every poll. + if ( + promptId !== undefined && + entry.enrichedTerminalPromptIds.has(promptId) + ) { + const enrichedTerminal = entry.terminalTurnStatuses.get(promptId); + if (enrichedTerminal) return enrichedTerminal; + } + let result: { v: number; sessionId: string; @@ -10253,6 +10296,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { sessionId, SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, { ...(promptId !== undefined ? { promptId } : {}) }, + // Transcript scans of large histories exceed the 10s init default; + // give the read the same budget as other transcript reads. + SESSION_TRANSCRIPT_TIMEOUT_MS, ); } catch (error) { const liveAfterFailure = findLiveTurnStatus(entry, promptId); @@ -10275,10 +10321,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : undefined; if (promptId !== undefined) { if (terminal && persisted) { - return mergeTerminalWithPersisted(terminal, persisted); + const merged = mergeTerminalWithPersisted(terminal, persisted); + rememberEnrichedTerminalTurnStatus(entry, promptId, merged); + return merged; } if (terminal) return terminal; - if (persisted) return persisted; + if (persisted) { + rememberEnrichedTerminalTurnStatus(entry, promptId, persisted); + return persisted; + } } else { if (terminal && persisted && terminal.promptId === persisted.promptId) { return mergeTerminalWithPersisted(terminal, persisted); @@ -10948,6 +10999,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } entry.terminalTurnStatuses.clear(); + entry.enrichedTerminalPromptIds.clear(); const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0; const filesChanged = (response['filesChanged'] as string[]) ?? []; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 298e0af1d6c..319aeaccb9e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4665,6 +4665,48 @@ describe('Session', () => { ); }); + it('treats an empty display text meta as absent for the turn record', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [ + { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': '' }, + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: '[image]', + }), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'projected prompt body' }], + _meta: { 'qwen.daemon.promptDisplayText': '' }, + }, + trustedContext, + ); + + expect( + mockChatRecordingService.recordTurnResult, + ).toHaveBeenLastCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: 'projected prompt body', + }), + ); + }); + it('accumulates streamed agent text into resultText', async () => { mockChat.sendMessageStream = vi.fn().mockResolvedValue( createStreamWithChunks([ @@ -4826,6 +4868,56 @@ describe('Session', () => { expect(payload.resultCode).toBe('RESULT_TEXT_TRUNCATED'); }); + it('keeps the same surface contract when many chunks exceed the cap', async () => { + const chunk = 'a'.repeat( + Math.ceil(core.TURN_RESULT_TEXT_MAX_CHARS / 2), + ); + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + ]), + ); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'long' }], + }, + trustedContext, + ); + + const payload = + mockChatRecordingService.recordTurnResult.mock.calls[0][0]; + expect(payload.resultText).toHaveLength( + core.TURN_RESULT_TEXT_MAX_CHARS, + ); + expect(payload.resultTruncated).toBe(true); + expect(payload.resultCode).toBe('RESULT_TEXT_TRUNCATED'); + }); + it('records a cancelled turn when admission aborts before dispatch', async () => { let releaseAdmission!: () => void; const admission = new Promise((resolve) => { @@ -5101,9 +5193,9 @@ describe('Session', () => { }); }); - it('records a superseded turn as cancelled when its stream throws', async () => { + it('records a superseded turn as cancelled when its stream throws the abort', async () => { mockConfig.assertCanStartTurn = vi.fn().mockResolvedValue(undefined); - let rejectFirst!: (error: Error) => void; + let rejectFirst!: (error: unknown) => void; const firstFailure = new Promise((_resolve, reject) => { rejectFirst = reject; }); @@ -5146,7 +5238,9 @@ describe('Session', () => { await vi.waitFor(() => expect(mockConfig.assertCanStartTurn).toHaveBeenCalledTimes(3), ); - rejectFirst(new Error('superseded stream aborted')); + rejectFirst( + new DOMException('superseded stream aborted', 'AbortError'), + ); const [firstResult, secondResult] = await Promise.allSettled([ first, @@ -5180,6 +5274,83 @@ describe('Session', () => { }); }); + it('surfaces a genuine failure after a successor abort instead of cancelling', async () => { + mockConfig.assertCanStartTurn = vi.fn().mockResolvedValue(undefined); + let rejectFirst!: (error: unknown) => void; + const firstFailure = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + const firstStream = (async function* () { + yield { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'partial' }] } }], + }, + }; + await firstFailure; + })(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(firstStream) + .mockResolvedValueOnce(createEmptyStream()); + + const first = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'first prompt' }], + }, + trustedContext, + ); + await vi.waitFor(() => + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1), + ); + + const second = session.prompt( + { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'second prompt' }], + }, + { + version: 1, + sessionId: 'test-session-id', + promptId: 'second-prompt-id', + }, + ); + await vi.waitFor(() => + expect(mockConfig.assertCanStartTurn).toHaveBeenCalledTimes(3), + ); + const genuineFailure = new Error('model backend failed'); + rejectFirst(genuineFailure); + + const [firstResult, secondResult] = await Promise.allSettled([ + first, + second, + ]); + expect(firstResult).toEqual({ + status: 'rejected', + reason: genuineFailure, + }); + expect(secondResult.status).toBe('fulfilled'); + + const firstPayload = + mockChatRecordingService.recordTurnResult.mock.calls + .map((call) => call[0]) + .find((payload) => payload.promptId === 'daemon-prompt-id'); + expect(firstPayload).toMatchObject({ + promptId: 'daemon-prompt-id', + state: 'error', + promptText: 'first prompt', + }); + const secondPayload = + mockChatRecordingService.recordTurnResult.mock.calls + .map((call) => call[0]) + .find((payload) => payload.promptId === 'second-prompt-id'); + expect(secondPayload).toMatchObject({ + promptId: 'second-prompt-id', + state: 'completed', + }); + }); + it('records a cancelled turn when cancelled while waiting for the predecessor', async () => { // The successor is admitted while the predecessor still streams, // then cancelled during the predecessor wait; it must settle its @@ -12992,13 +13163,31 @@ describe('Session', () => { }); it('does not apply the turn-result limit to channel delivery text', async () => { - const answer = 'x'.repeat(core.TURN_RESULT_TEXT_MAX_CHARS + 100); + // Multi-chunk on purpose: the accumulation cap must never apply to + // delivery turns, and a single chunk would pass even if it did (the + // first append is always accepted). + const chunk = 'x'.repeat( + Math.ceil(core.TURN_RESULT_TEXT_MAX_CHARS / 2) + 50, + ); + const answer = chunk + chunk + chunk; mockChat.sendMessageStream = vi.fn().mockResolvedValue( createStreamWithChunks([ { type: core.StreamEventType.CHUNK, value: { - candidates: [{ content: { parts: [{ text: answer }] } }], + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], + }, + }, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: chunk }] } }], }, }, ]), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9cbdd5480b8..df7db6882f1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1260,24 +1260,67 @@ interface AgentResponseCapture { agentOutput: AgentOutputMessageCapture; } +interface ChannelDeliveryResponseBlock { + parts: string[]; + chars: number; + /** + * When set, stop accumulating once `chars` reaches the cap; settle then + * sees a length past the turn-result bound and flags truncation. Only set + * for turns without a channel delivery — the delivery needs the full text, + * and capped turns would otherwise retain a multi-megabyte answer in full + * just to keep a truncated prefix. + */ + capChars?: number; +} + function beginChannelDeliveryResponseBlock( capture: AgentResponseCapture | undefined, -): string[] | undefined { +): ChannelDeliveryResponseBlock | undefined { capture?.agentOutput.beginResponse(); if (capture?.channelDelivery) capture.channelDelivery.finalText = ''; if (capture?.turnResult) capture.turnResult.finalText = ''; if (!capture?.channelDelivery && !capture?.turnResult) return undefined; - return []; + return { + parts: [], + chars: 0, + ...(capture?.channelDelivery + ? {} + : { capChars: TURN_RESULT_TEXT_MAX_CHARS + 1 }), + }; +} + +function appendChannelDeliveryResponseText( + responseBlock: ChannelDeliveryResponseBlock | undefined, + text: string, +): void { + if (!responseBlock) return; + if ( + responseBlock.capChars !== undefined && + responseBlock.chars >= responseBlock.capChars + ) { + return; + } + responseBlock.parts.push(text); + responseBlock.chars += text.length; +} + +function rewindChannelDeliveryResponseBlock( + responseBlock: ChannelDeliveryResponseBlock | undefined, + checkpoint: number, +): void { + if (!responseBlock) return; + const removed = responseBlock.parts.splice(checkpoint); + for (const part of removed) responseBlock.chars -= part.length; } function commitChannelDeliveryResponseBlock( capture: AgentResponseCapture | undefined, - responseBlock: string[] | undefined, + responseBlock: ChannelDeliveryResponseBlock | undefined, hasFunctionCalls: boolean, ): void { capture?.agentOutput.commitResponse(hasFunctionCalls); if (responseBlock && !hasFunctionCalls) { - const finalText = responseBlock.join(''); + const finalText = responseBlock.parts.join(''); if (capture?.channelDelivery) capture.channelDelivery.finalText = finalText; if (capture?.turnResult) capture.turnResult.finalText = finalText; } @@ -3548,11 +3591,21 @@ export class Session implements SessionContext { return result; } catch (error) { const pendingSend = turnRecording?.abortController; + const abortReason = + pendingSend?.signal.aborted === true + ? pendingSend.signal.reason + : undefined; + // Mirror the send-loop's controlled-cancellation contract: explicit + // user cancels and session disposal settle as `cancelled`. A + // successor-prompt abort does so only when the thrown error is the + // abort itself; the send loop deliberately excludes NEW_PROMPT from + // controlled cancellation so infrastructure failures are not hidden + // as cancellations, and a non-abort error landing after a successor + // aborted this turn is a real failure that must surface the same way. const controlledAbort = - pendingSend?.signal.aborted === true && - (pendingSend.signal.reason === USER_CANCEL_ABORT_REASON || - pendingSend.signal.reason === NEW_PROMPT_ABORT_REASON || - pendingSend.signal.reason === SESSION_DISPOSE_ABORT_REASON); + abortReason === USER_CANCEL_ABORT_REASON || + abortReason === SESSION_DISPOSE_ABORT_REASON || + (abortReason === NEW_PROMPT_ABORT_REASON && this.#isAbortError(error)); if (controlledAbort) { const result = { stopReason: 'cancelled' as const }; this.#settleTurnRecording('cancelled', turnRecording, result); @@ -4570,7 +4623,9 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( pendingSend.signal, ); - let channelDeliveryResponseBlock: string[] | undefined; + let channelDeliveryResponseBlock: + | ChannelDeliveryResponseBlock + | undefined; let channelDeliveryCheckpoint = 0; try { @@ -4611,7 +4666,7 @@ export class Session implements SessionContext { channelDeliveryResponseBlock = beginChannelDeliveryResponseBlock(responseCapture); channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + channelDeliveryResponseBlock?.parts.length ?? 0; let streamFailed = false; try { @@ -4639,7 +4694,10 @@ export class Session implements SessionContext { ); if (!part.thought) { responseCapture.agentOutput.appendText(part.text); - channelDeliveryResponseBlock?.push(part.text); + appendChannelDeliveryResponseText( + channelDeliveryResponseBlock, + part.text, + ); messageDisplay?.addChunk(part.text); } } @@ -4674,10 +4732,10 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, @@ -5315,7 +5373,9 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( pendingSend.signal, ); - let channelDeliveryResponseBlock: string[] | undefined; + let channelDeliveryResponseBlock: + | ChannelDeliveryResponseBlock + | undefined; let channelDeliveryCheckpoint = 0; let providerSendChat: GeminiChat | undefined; let userContentPushCountBeforeSend = 0; @@ -5617,7 +5677,8 @@ export class Session implements SessionContext { channelDeliveryResponseBlock = beginChannelDeliveryResponseBlock( options.responseCapture, ); - channelDeliveryCheckpoint = channelDeliveryResponseBlock?.length ?? 0; + channelDeliveryCheckpoint = + channelDeliveryResponseBlock?.parts.length ?? 0; initialSend = false; if (guardForThisSend) { const guardCommitted = this.todoStopGuard.commitContinuation( @@ -5658,7 +5719,10 @@ export class Session implements SessionContext { ); if (!part.thought) { options.responseCapture?.agentOutput.appendText(part.text); - channelDeliveryResponseBlock?.push(part.text); + appendChannelDeliveryResponseText( + channelDeliveryResponseBlock, + part.text, + ); messageDisplay?.addChunk(part.text); } } @@ -5692,9 +5756,10 @@ export class Session implements SessionContext { response.type === StreamEventType.MODEL_FALLBACK || !response.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, @@ -6004,9 +6069,15 @@ export class Session implements SessionContext { if (!invocationContext) return null; const promptMetadata = (params as { _meta?: Record }) ._meta; + const rawPromptDisplayText = + promptMetadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + // Treat an empty display text as absent so an image-only channel prompt + // still records `[image]` via the content fallback, without the bridge + // having to rewrite the forwarded value (which also feeds transcript + // displayText and telemetry outside this feature's surface). const promptDisplayText = - typeof promptMetadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === 'string' - ? promptMetadata[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + typeof rawPromptDisplayText === 'string' && rawPromptDisplayText !== '' + ? rawPromptDisplayText : undefined; const { text, truncated } = truncateTurnText( promptDisplayText ?? extractTurnPromptText(params.prompt), @@ -7257,10 +7328,12 @@ export class Session implements SessionContext { return; } const responseStream = sendResult.responseStream; - const channelDeliveryResponseBlock = + const channelDeliveryResponseBlock: + | ChannelDeliveryResponseBlock + | undefined = beginChannelDeliveryResponseBlock(responseCapture); const channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + channelDeliveryResponseBlock?.parts.length ?? 0; if (loopTick && turnCount === 1) { // The block reached the model (the send started); commit it so // the next tick can detect "unchanged". Deferring the commit @@ -7296,7 +7369,10 @@ export class Session implements SessionContext { ); if (!part.thought) { responseCapture.agentOutput.appendText(part.text); - channelDeliveryResponseBlock?.push(part.text); + appendChannelDeliveryResponseText( + channelDeliveryResponseBlock, + part.text, + ); messageDisplay?.addChunk(part.text); } } @@ -7331,10 +7407,10 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, From f7f155dfbb5d4c70a3f5c9d2695cc45d777cf492 Mon Sep 17 00:00:00 2001 From: hit_aran Date: Tue, 18 Aug 2026 11:26:24 +0800 Subject: [PATCH 7/7] fix(serve): guard turn-status reads against rewind races and keep the trusted prompt projection A successful rewind that completes while a getSessionTurnStatus child transcript scan is in flight could let the pre-rewind record be cached into the freshly cleared overlay and served forever. Track a per-session rewind generation captured before the scan and discard the scanned outcome when it moved. enrichTerminalTurnStatus and the deadline-supersede merge returned the child-recorded promptText ahead of the bridge's trusted display projection, leaking hidden channel context on the poll surface. Make promptText/promptTextTruncated backfill-only and keep the terminal's projection in the supersede path. Make the pinning test adversarial and correct a false comment about the child's ''-as-absent fallback. --- packages/acp-bridge/src/bridge.test.ts | 155 ++++++++++++++++++++++++- packages/acp-bridge/src/bridge.ts | 45 +++++-- 2 files changed, 190 insertions(+), 10 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 3c177736c9f..63e17409dc8 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -13185,7 +13185,8 @@ describe('createAcpSessionBridge', () => { { type: 'resource_link', uri: 'file:///visible', name: 'visible' }, ]); // The forwarded display text stays the worker's raw projection; the - // child's turn recording treats '' as absent and derives `[image]`. + // child's turn recording treats '' as absent and falls back to the + // prompt's first raw text block ('hidden first'), not `[image]`. expect( handle.agent.promptCalls[0]?._meta?.['qwen.daemon.promptDisplayText'], ).toBe(''); @@ -16439,6 +16440,74 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('does not resurrect a rewound-away result when a scan resolves late', async () => { + const lookup = deferred<{ + v: number; + sessionId: string; + turnResult: unknown; + }>(); + let lookupStarted = false; + let rewound = false; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + if (rewound) { + return { v: 1, sessionId: 'ignored', turnResult: null }; + } + lookupStarted = true; + return lookup.promise; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionRewind) { + rewound = true; + return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] }; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'settles before rewind' }], + }, + undefined, + { promptId: 'prompt-rewind-success-race' }, + ); + + const status = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-rewind-success-race', + ); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + await bridge.rewindSession(session.sessionId, { promptId: 'target' }); + lookup.resolve({ + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-rewind-success-race', + state: 'completed', + stopReason: 'end_turn', + endedAt: 1, + resultText: 'rewound-away answer', + }, + }); + + // The late pre-rewind record must be neither returned nor cached. + await expect(status).resolves.toBeUndefined(); + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-rewind-success-race', + ), + ).resolves.toBeUndefined(); + await bridge.shutdown(); + }); + it('keeps a removed running prompt visible until it settles', async () => { const turn = deferred(); const handle = makeChannel({ @@ -16741,6 +16810,66 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('keeps the trusted prompt projection when a persisted result supersedes a deadline error', async () => { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + cancelImpl: () => new Promise(() => {}), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-supersede-display', + state: 'completed', + stopReason: 'end_turn', + endedAt: 1, + promptText: 'internal channel instructions\n\nvisible ask', + resultText: 'settled after deadline', + }, + } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sourceType: 'channel', + }); + const p1 = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [ + { + type: 'text', + text: 'internal channel instructions\n\nvisible ask', + }, + ], + }, + undefined, + { + promptId: 'prompt-supersede-display', + promptDisplayText: 'visible ask', + deadlineMs: 50, + }, + ); + await expect(p1).rejects.toBeInstanceOf(PromptDeadlineExceededError); + + // The persisted non-error outcome supersedes the deadline error, but + // the prompt display projection stays the bridge's trusted one. + const status = await bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-supersede-display', + ); + expect(status).toMatchObject({ + state: 'completed', + promptText: 'visible ask', + resultText: 'settled after deadline', + }); + await bridge.shutdown(); + }); + it('keeps the trusted prompt display projection after persistence', async () => { const handle = makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }), @@ -16754,13 +16883,21 @@ describe('createAcpSessionBridge', () => { state: 'completed', stopReason: 'end_turn', endedAt: 1, - promptText: 'hello', + // The child's record carries its raw-block derivation + // (hidden context included); the trusted projection must + // still win on the poll surface, while the persisted + // result text stays authoritative. + promptText: 'internal channel instructions\n\nhello', + resultText: 'settled answer', }, } : {}, }); const bridge = makeBridge({ channelFactory: async () => handle.channel }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sourceType: 'channel', + }); await bridge.sendPrompt( session.sessionId, { @@ -16776,6 +16913,18 @@ describe('createAcpSessionBridge', () => { { promptId: 'prompt-display', promptDisplayText: 'hello' }, ); + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-display', + ), + ).resolves.toMatchObject({ + state: 'completed', + promptText: 'hello', + resultText: 'settled answer', + }); + // The enriched fast path serves the same trusted projection. await expect( bridge.getSessionTurnStatus( session.sessionId, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 9f569469ff3..feb86d09726 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1012,6 +1012,14 @@ interface SessionEntry { * transcript. */ enrichedTerminalPromptIds: Set; + /** + * Monotonic counter incremented when a successful rewind truncates this + * session's history. `getSessionTurnStatus` captures it before scanning + * the child transcript and discards the scanned outcome when the + * generation moved, so a result rolled back by a concurrent rewind is + * never cached or served. + */ + rewindGeneration: number; /** Bridge prompt that owns the child Guard wait for this FIFO. */ todoStopGuardAwaitingQueuedPromptOwnerPromptId?: string; /** @@ -2175,10 +2183,14 @@ function enrichTerminalTurnStatus( ): BridgeTurnStatus { return { ...terminal, - ...(persisted.promptText !== undefined + // The bridge's display projection is trusted; the child-recorded text + // only backfills when the terminal has none, so hidden channel context + // the child derived from raw blocks never replaces it. + ...(terminal.promptText === undefined && persisted.promptText !== undefined ? { promptText: persisted.promptText } : {}), - ...(persisted.promptTextTruncated !== undefined + ...(terminal.promptTextTruncated === undefined && + persisted.promptTextTruncated !== undefined ? { promptTextTruncated: persisted.promptTextTruncated } : {}), ...(persisted.resultText !== undefined @@ -2203,7 +2215,8 @@ function enrichTerminalTurnStatus( * flush) is superseded on the poll surface once the child has settled and * persisted a non-error outcome: the deadline releases the caller without * killing the agent, so the persisted outcome is what actually happened. - * Every other combination keeps the overlay outcome and enriches it with + * The trusted prompt display projection always stays the terminal's. Every + * other combination keeps the overlay outcome and enriches it with * persisted text. */ function mergeTerminalWithPersisted( @@ -2211,7 +2224,15 @@ function mergeTerminalWithPersisted( persisted: BridgeTurnStatus, ): BridgeTurnStatus { if (terminal.state === 'error' && persisted.state !== 'error') { - return persisted; + return { + ...persisted, + ...(terminal.promptText !== undefined + ? { promptText: terminal.promptText } + : {}), + ...(terminal.promptTextTruncated !== undefined + ? { promptTextTruncated: terminal.promptTextTruncated } + : {}), + }; } return enrichTerminalTurnStatus(terminal, persisted); } @@ -5764,6 +5785,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingPromptList: [], terminalTurnStatuses: new Map(), enrichedTerminalPromptIds: new Set(), + rewindGeneration: 0, midTurnMessageQueue: [], settledMidTurnMessageIds: [], promotedMidTurnMessageIds: [], @@ -10560,6 +10582,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (enrichedTerminal) return enrichedTerminal; } + const rewindGenerationBeforeRead = entry.rewindGeneration; let result: { v: number; sessionId: string; @@ -10590,9 +10613,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { promptId !== undefined ? entry.terminalTurnStatuses.get(promptId) : latestTerminalTurnStatus(entry); - const persisted = result.turnResult - ? settledTurnStatus(sessionId, result.turnResult) - : undefined; + // A rewind that completed while the scan was in flight may have + // rolled back the scanned outcome; drop it so neither the write-back + // nor the return below resurrects a rewound-away result. Rewind also + // cleared the overlay this read falls back to, so the failure path + // needs no equivalent guard. + const persisted = + result.turnResult && + entry.rewindGeneration === rewindGenerationBeforeRead + ? settledTurnStatus(sessionId, result.turnResult) + : undefined; if (promptId !== undefined) { if (terminal && persisted) { const merged = mergeTerminalWithPersisted(terminal, persisted); @@ -11370,6 +11400,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.terminalTurnStatuses.clear(); entry.enrichedTerminalPromptIds.clear(); + entry.rewindGeneration += 1; const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0; const filesChanged = (response['filesChanged'] as string[]) ?? [];