diff --git a/docs/design/daemon-turn-status-endpoint.md b/docs/design/daemon-turn-status-endpoint.md new file mode 100644 index 00000000000..fc72a8cb1af --- /dev/null +++ b/docs/design/daemon-turn-status-endpoint.md @@ -0,0 +1,46 @@ +# 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 while the prompt remains in the live queue or in-process overlay; persisted-only results can omit it. `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 prompt has `promptTextTruncated: true`; 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. 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. + +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/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index a496e35ae0c..6d7f98e97a7 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -24,16 +24,16 @@ **Middleware** (`packages/cli/src/serve/auth.ts` and `server.ts`): -| Middleware, in registration order | Purpose | Notes | -| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin ` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). | -| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. | -| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. | -| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. | -| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. | -| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. | -| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. | -| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. | +| Middleware, in registration order | Purpose | Notes | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin ` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). | +| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. | +| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. | +| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. | +| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. | +| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. | +| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. | +| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. | **Subsystems**: diff --git a/docs/developers/daemon/12-auth-security.md b/docs/developers/daemon/12-auth-security.md index d91d7ec87e8..9480ddc3837 100644 --- a/docs/developers/daemon/12-auth-security.md +++ b/docs/developers/daemon/12-auth-security.md @@ -120,13 +120,13 @@ Control is enabled (the LAN origin is added/removed with the listener): Per-route opt-in gate. Behavior matrix: -| daemon config | route opts | result | -| ----------------------- | --------------- | -------------------------------- | -| `requireAuth=true` | any | passthrough¹ | -| `token` configured | any | passthrough² | -| no token (loopback dev) | `strict: false` | passthrough | +| daemon config | route opts | result | +| ----------------------- | ------------------------------- | -------------------------------- | +| `requireAuth=true` | any | passthrough¹ | +| `token` configured | any | passthrough² | +| no token (loopback dev) | `strict: false` | passthrough | | no token (loopback dev) | `strict: true`, unauthenticated | `401 { code: 'token_required' }` | -| no token (loopback dev) | `strict: true`, authenticated³ | passthrough | +| no token (loopback dev) | `strict: true`, authenticated³ | passthrough | ¹ `--require-auth` boots only with a token, so global `bearerAuth` already 401'd unauthenticated callers. ² Any token configuration makes global `bearerAuth` enforce bearer-required-everywhere; the gate is redundant but harmless. diff --git a/docs/developers/daemon/18-error-taxonomy.md b/docs/developers/daemon/18-error-taxonomy.md index 4e2b09d75a7..053b96b2a3f 100644 --- a/docs/developers/daemon/18-error-taxonomy.md +++ b/docs/developers/daemon/18-error-taxonomy.md @@ -105,7 +105,7 @@ These are surfaced through the preflight cell's `errorKind` so client UIs render | ------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `{ error: 'Unauthorized' }` | Missing / wrong / no-scheme bearer token. Uniform across `missing header` / `wrong scheme` / `wrong token` so probing cannot distinguish. | | `401` | `{ error: '...', code: 'token_required' }` | Mutation-gate strict route on a no-token loopback daemon. SDKs render "configure --token / --require-auth" hint. | -| `403` | `{ error: 'Request denied by CORS policy' }` | `allowOriginCors` (runtime) / `denyBrowserOriginCors` (bootstrap) rejected an `Origin`-bearing request. | +| `403` | `{ error: 'Request denied by CORS policy' }` | `allowOriginCors` (runtime) / `denyBrowserOriginCors` (bootstrap) rejected an `Origin`-bearing request. | | `403` | `{ error: 'Invalid Host header' }` | `hostAllowlist` rejected the `Host` header (DNS rebinding defense). | See [`12-auth-security.md`](./12-auth-security.md) for the full auth model. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 12b5e1fd363..5228c7a80ba 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -502,6 +502,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. @@ -2510,7 +2512,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/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index daf03659a36..09239f9b65f 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -385,7 +385,7 @@ Notes: | `--port ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | | `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | | `--local-control` | `false` | Share the Web Shell on one selected private IPv4 interface with a daemon-owned revocable pairing token, terminal QR code, exact browser origin, and best-effort sleep inhibition. Composes with `--token`, `--allow-origin`, and `--port 0`; conflicts with `--no-web` and non-default `--hostname`. Use `--local-control-address` when multiple LAN candidates are available, and add `--tls-cert` + `--tls-key` for secure-context browser APIs such as voice input. | -| `--local-control-address ` | — | Which LAN IPv4 address to share when the host has more than one candidate. Only needed if `--local-control` reports an ambiguous choice. | +| `--local-control-address ` | — | Which LAN IPv4 address to share when the host has more than one candidate. Only needed if `--local-control` reports an ambiguous choice. | | `--token ` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). | | `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. | | `--tls-cert ` | — | Path to a PEM certificate file. Serve over **HTTPS** instead of HTTP. Must be paired with `--tls-key` (boot fails if only one is given). Unlocks secure-context browser APIs — voice input (`getUserMedia`), WebRTC — over a LAN IP, which browsers otherwise block on plain `http://`. TLS termination only; no auto-generation / ACME. See [HTTPS / TLS](#https--tls-for-mobile--cross-device-access) below. | diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 0c4a6993ffb..96868cbb36f 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_media', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts index 2e3d569e867..8a425edfcdd 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,121 @@ 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); + + it('reads a settled result after a normal Session reload', 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-reload-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: 'fake response complete', + }); + + await client.closeSession(session.sessionId); + await client.loadSession(session.sessionId, { + workspaceCwd: workspaceDir, + }); + await expect + .poll(() => turnStatus(session.sessionId, accepted.promptId), { + timeout: 30_000, + }) + .toMatchObject({ + state: 'completed', + stopReason: 'end_turn', + resultText: 'fake response complete', + }); + } 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 46cb956efd1..63e17409dc8 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -99,6 +99,8 @@ import { ShellExecutionService, stableSessionArtifactId, ToolNames, + TURN_RESULT_CODE_TEXT_TRUNCATED, + TURN_RESULT_TEXT_MAX_CHARS, } from '@qwen-code/qwen-code-core'; import { FakeAgent, @@ -13169,6 +13171,7 @@ describe('createAcpSessionBridge', () => { prompt: [ { type: 'text', text: 'hidden first' }, { type: 'text', text: 'hidden second' }, + { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, { type: 'resource_link', uri: 'file:///visible', name: 'visible' }, ], }, @@ -13178,8 +13181,15 @@ describe('createAcpSessionBridge', () => { await drain; expect(userChunks).toEqual([ + { 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 falls back to the + // prompt's first raw text block ('hidden first'), not `[image]`. + expect( + handle.agent.promptCalls[0]?._meta?.['qwen.daemon.promptDisplayText'], + ).toBe(''); abort.abort(); await bridge.shutdown(); }); @@ -15640,6 +15650,1384 @@ 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; + let admittedPromptCallCount: number | undefined; + + const prompt = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'dispatch boundary' }], + }, + undefined, + { + promptId: 'prompt-dispatch-boundary', + onPromptAdmitted: () => { + admittedPromptCallCount = handle.agent.promptCalls.length; + 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'); + // 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(); + }); + + 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; + + 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(); + }); + + 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('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', + 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' }), + 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('does not resurrect an overlay cleared by rewind while a read fails', async () => { + const lookup = deferred(); + let lookupStarted = false; + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus) { + lookupStarted = true; + return lookup.promise; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionRewind) { + 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 during read' }], + }, + undefined, + { promptId: 'prompt-rewind-read-race' }, + ); + + const status = bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-rewind-read-race', + ); + await vi.waitFor(() => expect(lookupStarted).toBe(true)); + await bridge.rewindSession(session.sessionId, { promptId: 'target' }); + lookup.reject(new Error('transcript unavailable')); + + await expect(status).rejects.toThrow(); + 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({ + promptImpl: () => turn.promise, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const prompt = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'cancel while running' }], + }, + undefined, + { promptId: 'prompt-running-remove' }, + ); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + + expect( + bridge.removePendingPrompt(session.sessionId, 'prompt-running-remove'), + ).toEqual({ removed: true }); + await expect( + bridge.getSessionTurnStatus( + session.sessionId, + undefined, + 'prompt-running-remove', + ), + ).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-running-remove', + }); + await expect( + bridge.getSessionTurnStatus(session.sessionId), + ).resolves.toMatchObject({ + state: 'running', + promptId: 'prompt-running-remove', + }); + + 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(); + }); + + 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' }, + ); + + 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(); + }); + + 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 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' }), + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionTurnStatus + ? { + v: 1, + sessionId: 'ignored', + turnResult: { + promptId: 'prompt-display', + state: 'completed', + stopReason: 'end_turn', + endedAt: 1, + // 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, + sourceType: 'channel', + }); + 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', + resultText: 'settled answer', + }); + // The enriched fast path serves the same trusted projection. + 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 9f081f63988..feb86d09726 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -27,6 +27,7 @@ import type { import type { ApprovalMode, RebuiltSessionArtifactSnapshot, + TurnResultRecordPayload, } from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, @@ -36,7 +37,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, @@ -162,6 +166,7 @@ import type { BridgeRestoredSession, BridgeSessionGoal, BridgeSessionSummary, + BridgeTurnStatus, BridgeSessionCatalogVersion, BridgePendingInteraction, BridgeClientRequestContext, @@ -998,6 +1003,23 @@ interface SessionEntry { * tail of `sendPrompt`. */ 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; + /** + * 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; /** @@ -1850,6 +1872,91 @@ 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); + // 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); + } +} + /** * Publish the formal terminal event for an accepted prompt exactly once. * All terminal paths (agent settle, queued removal, deadline, session @@ -1875,6 +1982,7 @@ function publishPromptTerminal( return; } pendingEntry.terminalPublished = true; + rememberTerminalTurnStatus(entry, pendingEntry, terminal); const originatorClientId = pendingEntry.originatorClientId; // Only a running prompt's terminal belongs to the active turn. The // `state === 'running'` gate (not `activePromptId`) is deliberate: on @@ -1994,6 +2102,151 @@ 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.terminalPublished && + (!pending.removed || pending.state === 'running'), + ); + 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, + // 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 } + : {}), + ...(terminal.promptTextTruncated === undefined && + 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 } + : {}), + }; +} + +/** + * 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. + * 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( + terminal: BridgeTurnStatus, + persisted: BridgeTurnStatus, +): BridgeTurnStatus { + if (terminal.state === 'error' && persisted.state !== 'error') { + return { + ...persisted, + ...(terminal.promptText !== undefined + ? { promptText: terminal.promptText } + : {}), + ...(terminal.promptTextTruncated !== undefined + ? { promptTextTruncated: terminal.promptTextTruncated } + : {}), + }; + } + return enrichTerminalTurnStatus(terminal, persisted); +} + +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; +} + /** * Extract inline media content blocks from a prompt for storage in the * pending-prompt queue. Image references use the session media store; legacy @@ -5530,6 +5783,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingPromptCount: 0, pendingAgentNotificationCount: 0, pendingPromptList: [], + terminalTurnStatuses: new Map(), + enrichedTerminalPromptIds: new Set(), + rewindGeneration: 0, midTurnMessageQueue: [], settledMidTurnMessageIds: [], promotedMidTurnMessageIds: [], @@ -8013,6 +8269,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 @@ -10293,7 +10550,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, @@ -10306,6 +10563,95 @@ 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; + + // 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; + } + + const rewindGenerationBeforeRead = entry.rewindGeneration; + let result: { + v: number; + sessionId: string; + turnResult: TurnResultRecordPayload | null; + }; + try { + result = await requestSessionStatus( + 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); + if (liveAfterFailure) return liveAfterFailure; + const terminalAfterFailure = + promptId !== undefined + ? entry.terminalTurnStatuses.get(promptId) + : latestTerminalTurnStatus(entry); + if (terminalAfterFailure) return terminalAfterFailure; + throw error; + } + const liveAfterRead = findLiveTurnStatus(entry, promptId); + if (liveAfterRead) return liveAfterRead; + const terminal = + promptId !== undefined + ? entry.terminalTurnStatuses.get(promptId) + : latestTerminalTurnStatus(entry); + // 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); + rememberEnrichedTerminalTurnStatus(entry, promptId, merged); + return merged; + } + if (terminal) return terminal; + if (persisted) { + rememberEnrichedTerminalTurnStatus(entry, promptId, persisted); + return persisted; + } + } else { + if (terminal && persisted && terminal.promptId === persisted.promptId) { + return mergeTerminalWithPersisted(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 }; + }, + async storeSessionMedia(sessionId, data, mimeType, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -11052,6 +11398,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw err; } + entry.terminalTurnStatuses.clear(); + entry.enrichedTerminalPromptIds.clear(); + entry.rewindGeneration += 1; + 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 95e1b18aff4..39136c8a05e 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, @@ -933,6 +935,7 @@ export interface BridgeMidTurnMessagesSnapshot { export interface PendingPromptEntry { promptId: string; queuedAt: number; + startedAt?: number; originatorClientId?: string; promotedMidTurn?: true; text: string; @@ -986,6 +989,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; @@ -1276,6 +1296,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 c6b6159d63a..04bb5b8dda8 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -207,6 +207,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 @@ -10048,6 +10051,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 ef20078debe..a0c470d83d6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -72,6 +72,7 @@ import { SessionTranscriptSnapshotUnavailableError, SessionTranscriptTooLargeError, encodeSessionTranscriptCursor, + isTurnResultRecordPayload, subagentGenerator, redactUrlCredentials, computeUniqueBranchTitle, @@ -125,6 +126,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'; @@ -416,6 +418,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' @@ -11044,6 +11086,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 61a1bc59579..deeace47691 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -421,6 +421,7 @@ describe('Session', () => { let switchModelSpy: ReturnType; let getAvailableCommandsSpy: ReturnType; let mockChatRecordingService: { + recordTurnResult: ReturnType; recordUserMessage: ReturnType; recordGoalRuntimeMessage: ReturnType; recordMidTurnUserMessage: ReturnType; @@ -691,6 +692,7 @@ describe('Session', () => { }; mockChatRecordingService = { + recordTurnResult: vi.fn(), recordUserMessage: vi.fn(), recordGoalRuntimeMessage: vi.fn(), recordMidTurnUserMessage: vi.fn(), @@ -4495,6 +4497,945 @@ 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('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() + .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('records the image placeholder used by the live turn status', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt: [ + { + type: 'text', + text: 'hidden channel instructions', + }, + { + type: 'image', + mimeType: 'image/png', + data: 'aGVsbG8=', + }, + ], + _meta: { 'qwen.daemon.promptDisplayText': '[image]' }, + }, + trustedContext, + ); + + expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith( + expect.objectContaining({ + promptId: 'daemon-prompt-id', + promptText: '[image]', + }), + ); + }); + + 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('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([ + { + 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).toHaveBeenCalledTimes( + 1, + ); + 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('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) => { + 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', + stopReason: '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' }, + }), + ); + 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 an abort-shaped 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('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() + .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 superseded turn as cancelled when its stream throws the abort', 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), + ); + rejectFirst( + new DOMException('superseded stream aborted', 'AbortError'), + ); + + const [firstResult, secondResult] = await Promise.allSettled([ + first, + second, + ]); + expect(firstResult).toEqual({ + status: 'fulfilled', + value: { stopReason: 'cancelled' }, + }); + 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({ + state: 'cancelled', + 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('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 + // 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', + stopReason: 'cancelled', + promptText: 'second prompt', + }); + }); + }); + it('rejects a trusted context for a different session', async () => { const trustedContext: core.InvocationContextV1 = { version: 1, @@ -4514,6 +5455,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 () => { @@ -12731,6 +13673,73 @@ describe('Session', () => { expect(capture.writeToSpan).toHaveBeenCalledWith(agentTelemetry.span); }); + it('does not apply the turn-result limit to channel delivery text', async () => { + // 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: 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 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 () => { agentTelemetry.getActiveInteractionSpan.mockReturnValue( agentTelemetry.span, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f86568e74fa..a4b24e30418 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -49,6 +49,8 @@ import type { MemoryWriteCandidate, CronTaskDelivery, InvocationContextV1, + ChatRecordingService, + TurnResultRecordPayload, WorkflowApproval, BranchPoint, } from '@qwen-code/qwen-code-core'; @@ -181,6 +183,9 @@ import { getFullTurnVisionModelSelector, splitImageParts, approxBase64Bytes, + normalizeTurnResultError, + TURN_RESULT_CODE_TEXT_TRUNCATED, + TURN_RESULT_TEXT_MAX_CHARS, runWithRuntimeContentGenerator, observeToolResultBoundary, toolResultBoundaryArtifact, @@ -1060,6 +1065,42 @@ function hasInlineMediaContentBlock(content: ContentBlock[]): boolean { return content.some((part) => part.type === 'image' || part.type === 'audio'); } +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]' : ''; +} + +interface InFlightTurnRecording { + promptId: string; + originatorClientId?: string; + abortController?: AbortController; + startedAt?: number; + 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): { + 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 stripReferencedInlineDataParts( parts: Part[], content: ContentBlock[], @@ -1320,26 +1361,75 @@ interface AgentResponseCapture { channelDelivery?: { finalText: string; }; + turnResult?: { + finalText: string; + }; 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) return undefined; - capture.channelDelivery.finalText = ''; - return []; + if (capture?.channelDelivery) capture.channelDelivery.finalText = ''; + if (capture?.turnResult) capture.turnResult.finalText = ''; + if (!capture?.channelDelivery && !capture?.turnResult) return undefined; + 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 (capture?.channelDelivery && responseBlock && !hasFunctionCalls) { - capture.channelDelivery.finalText = responseBlock.join(''); + if (responseBlock && !hasFunctionCalls) { + const finalText = responseBlock.parts.join(''); + if (capture?.channelDelivery) capture.channelDelivery.finalText = finalText; + if (capture?.turnResult) capture.turnResult.finalText = finalText; } } @@ -3580,6 +3670,66 @@ export class Session implements SessionContext { admissionCancellation?: AbortSignal, modelPrompt?: string, scheduledGoalTurn?: AcpGoalTurn, + ): Promise { + if ( + invocationContext !== undefined && + invocationContext.sessionId !== this.config.getSessionId() + ) { + throw RequestError.invalidParams( + undefined, + 'Invocation context session does not match the active session', + ); + } + const turnRecording = this.#beginTurnRecording(params, invocationContext); + try { + const result = await this.#promptWithTurnRecording( + params, + invocationContext, + admissionCancellation, + modelPrompt, + scheduledGoalTurn, + turnRecording, + ); + this.#settleTurnRecording( + result.stopReason === 'cancelled' ? 'cancelled' : 'completed', + turnRecording, + result, + ); + 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 = + 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); + return result; + } + this.#settleTurnRecording('error', turnRecording, undefined, error); + throw error; + } + } + + async #promptWithTurnRecording( + params: PromptRequest, + invocationContext: InvocationContextV1 | undefined, + admissionCancellation: AbortSignal | undefined, + modelPrompt: string | undefined, + scheduledGoalTurn: AcpGoalTurn | undefined, + turnRecording: InFlightTurnRecording | null, ): Promise { if (this.closing) { throw RequestError.invalidParams(undefined, 'Session is closing'); @@ -3656,6 +3806,7 @@ export class Session implements SessionContext { // targets us. A cancel during admission cannot target this pending prompt. this.pendingPrompt?.abort(NEW_PROMPT_ABORT_REASON); const pendingSend = goalTurn?.controller ?? new AbortController(); + if (turnRecording) turnRecording.abortController = pendingSend; const cancelPendingSend = () => pendingSend.abort(USER_CANCEL_ABORT_REASON); if (admissionCancellation) { admissionCancellation.addEventListener('abort', cancelPendingSend, { @@ -3801,6 +3952,7 @@ export class Session implements SessionContext { const channelDelivery = parsePromptChannelDelivery(params); const responseCapture: AgentResponseCapture = { ...(channelDelivery ? { channelDelivery: { finalText: '' } } : {}), + ...(turnRecording ? { turnResult: turnRecording.finalAnswer } : {}), agentOutput: new AgentOutputMessageCapture(this.config), }; // One server-side channel classification, consumed by both the @@ -3822,6 +3974,7 @@ export class Session implements SessionContext { let rejectedByLoopProtection = false; let promptResult: PromptResponse | undefined; let promptFailed = false; + if (turnRecording) turnRecording.startedAt = Date.now(); try { const result = await this.#executePrompt( params, @@ -4650,7 +4803,9 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( pendingSend.signal, ); - let channelDeliveryResponseBlock: string[] | undefined; + let channelDeliveryResponseBlock: + | ChannelDeliveryResponseBlock + | undefined; let channelDeliveryCheckpoint = 0; try { @@ -4691,7 +4846,7 @@ export class Session implements SessionContext { channelDeliveryResponseBlock = beginChannelDeliveryResponseBlock(responseCapture); channelDeliveryCheckpoint = - channelDeliveryResponseBlock?.length ?? 0; + channelDeliveryResponseBlock?.parts.length ?? 0; let streamFailed = false; try { @@ -4719,7 +4874,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); } } @@ -4754,10 +4912,10 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, @@ -5395,7 +5553,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; @@ -5697,7 +5857,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( @@ -5738,7 +5899,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); } } @@ -5772,9 +5936,10 @@ export class Session implements SessionContext { response.type === StreamEventType.MODEL_FALLBACK || !response.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, @@ -6079,6 +6244,80 @@ 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 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 rawPromptDisplayText === 'string' && rawPromptDisplayText !== '' + ? rawPromptDisplayText + : undefined; + const { text, truncated } = truncateTurnText( + promptDisplayText ?? extractTurnPromptText(params.prompt), + ); + const recordingService = this.config.getChatRecordingService(); + return { + promptId: invocationContext.promptId, + ...(invocationContext.originatorClientId !== undefined + ? { originatorClientId: invocationContext.originatorClientId } + : {}), + promptText: text, + promptTextTruncated: truncated, + finalAnswer: { finalText: '' }, + ...(recordingService !== undefined ? { recordingService } : {}), + }; + } + + #settleTurnRecording( + state: 'completed' | 'cancelled' | 'error', + recording: InFlightTurnRecording | null, + response?: PromptResponse, + error?: unknown, + ): void { + if (recording === null) return; + const finalAnswer = truncateTurnText(recording.finalAnswer.finalText); + const stopReason = + response?.stopReason ?? (state === 'cancelled' ? 'cancelled' : undefined); + const payload: TurnResultRecordPayload = { + promptId: recording.promptId, + state, + ...(stopReason !== undefined ? { 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 { + recording.recordingService?.recordTurnResult(payload); + } catch (recordError) { + debugLogger.warn( + `Failed to record turn result: ${this.#formatError(recordError)}`, + ); + } + } + #getCurrentChat(): GeminiChat { return this.config.getGeminiClient()!.getChat(); } @@ -7286,10 +7525,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 @@ -7325,7 +7566,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); } } @@ -7360,10 +7604,10 @@ export class Session implements SessionContext { resp.type === StreamEventType.MODEL_FALLBACK || !resp.isContinuation ) { - if (channelDeliveryResponseBlock) { - channelDeliveryResponseBlock.length = - channelDeliveryCheckpoint; - } + rewindChannelDeliveryResponseBlock( + channelDeliveryResponseBlock, + channelDeliveryCheckpoint, + ); } await finalizeToolCallPreparations( preparationTracker, diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 53a47436e0e..3f5b1e06a32 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -84,6 +84,7 @@ export type { BridgeWorkspaceGenerationStreamEvent, BridgePromptContentBlock, BridgeSessionSummary, + BridgeTurnStatus, BridgeSessionCatalogVersion, SessionMetadataUpdate, BridgeClientRequestContext, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 7af94d5f788..f61d15fbe88 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' }, // Prompts and mid-turn messages support session-scoped media uploaded once // and referenced by `mediaId`. The bridge resolves bytes only when ACP input // is dispatched, keeping base64 out of JSON and SSE payloads. diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 83039c5c4c7..58e891743c3 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -692,10 +692,7 @@ describe('serve fast path argument parsing', () => { ['web', ['--no-web']], ['open', ['--open']], ['local-control', ['--local-control']], - [ - 'local-control-address', - ['--local-control-address', '192.168.1.2'], - ], + ['local-control-address', ['--local-control-address', '192.168.1.2']], ['http-bridge', ['--no-http-bridge']], ['memory-budget-mb', ['--memory-budget-mb', '8192']], ['memory-pressure-mode', ['--memory-pressure-mode', 'observe']], diff --git a/packages/cli/src/serve/live/live-session-coordinator.test.ts b/packages/cli/src/serve/live/live-session-coordinator.test.ts index 08433ff0b70..ec8fc7da7ce 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.test.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.test.ts @@ -432,7 +432,11 @@ describe('LiveSessionCoordinator', () => { const harness = makeHarness(); harness.host.setCoordinator.mockReturnValueOnce(false); - await harness.coordinator.start({ epoch: 1, callId: 'call-1', mode: 'new' }); + await harness.coordinator.start({ + epoch: 1, + callId: 'call-1', + mode: 'new', + }); expect(harness.host.failCall).toHaveBeenCalledWith( 1, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index f75089a947b..aa615d92b71 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -6222,6 +6222,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 8291229f78c..4e18894631b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -119,6 +119,7 @@ import { type BridgeDaemonStatusSnapshot, type BridgeRestoredSession, type BridgeClientRequestContext, + type BridgeTurnStatus, type BridgeRestoreSessionRequest, type BridgeSession, type BridgeSessionSummary, @@ -534,6 +535,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_source_metadata', 'session_side_task', 'session_prompt', + 'session_turn_status', 'session_media', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', @@ -814,6 +816,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, @@ -1073,6 +1080,7 @@ interface FakeBridge extends AcpSessionBridge { NonNullable >[0]; calls: BridgeSpawnRequest[]; + getSessionTurnStatusCalls: Array<{ sessionId: string; promptId?: string }>; loadCalls: BridgeRestoreSessionRequest[]; resumeCalls: BridgeRestoreSessionRequest[]; promptCalls: Array<{ @@ -1327,6 +1335,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[] = []; @@ -1908,6 +1922,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { liveSpeakToUserHandler = handler; }, calls, + getSessionTurnStatusCalls, loadCalls, resumeCalls, promptCalls, @@ -2360,6 +2375,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, @@ -10034,6 +10056,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 d47dee8eb5c..4c8c7a21f40 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(57); + expect(registered).toHaveLength(59); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 9e8b523bd79..416b6a0e18b 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -820,17 +820,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 57 unique routes with the audited 55/2 attribution split', () => { + it('contains 59 unique routes with the audited 57/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(57); - expect(new Set(keys).size).toBe(57); + expect(keys).toHaveLength(59); + expect(new Set(keys).size).toBe(59); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(55); + ).toHaveLength(57); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 3dea704ed16..696f60cd6af 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -317,6 +317,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/config/config.test.ts b/packages/core/src/config/config.test.ts index 88540c4572d..d3c94c9eab9 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2262,6 +2262,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({ @@ -2713,24 +2733,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 a77aff93194..0750ee98d67 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4003,6 +4003,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 fb7de7b94d3..f05c82aa17e 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -12,8 +12,14 @@ 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_IDENTIFIER_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'; @@ -25,6 +31,7 @@ import { type FileHistorySnapshot, } from './fileHistoryService.js'; import { + SessionWriterLostError, SessionTranscriptChangedError, SessionWriterUnavailableError, type SessionWriterLease, @@ -1326,6 +1333,283 @@ 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('preserves the RPC code of session writer errors', () => { + expect(normalizeTurnResultError(new SessionWriterLostError())).toEqual( + expect.objectContaining({ code: '-32021' }), + ); + }); + + 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('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({ + 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', + 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('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({ + promptId: 'prompt-1', + state: 'completed', + endedAt: 2_000, + }); + await chatRecordingService.recordSessionArtifactEvent({ + v: 2, + sessionId: 'test-session-id', + sequence: 1, + recordedAt: '2026-08-14T00:00:00.000Z', + changes: [], + }); + chatRecordingService.recordUserMessage([{ text: 'after result' }]); + await chatRecordingService.flush(); + + const records = vi + .mocked(jsonl.writeLine) + .mock.calls.map((call) => call[1] as ChatRecord); + const before = records[0]!; + const turnResult = records[1]!; + const artifact = records[2]!; + const after = records[3]!; + expect(turnResult.subtype).toBe('turn_result'); + expect(turnResult.parentUuid).toBe(before.uuid); + expect(artifact.parentUuid).toBe(turnResult.uuid); + expect(after.parentUuid).toBe(turnResult.uuid); + }); + + 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('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', () => { 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 96408eacaa2..4b292fb3680 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -316,7 +316,8 @@ export interface ChatRecord { | 'branch_checkpoint' | '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. */ @@ -376,7 +377,8 @@ export interface ChatRecord { | SessionArtifactEventRecordPayload | SessionArtifactSnapshotRecordPayload | BranchCheckpointRecordPayloadV1 - | GoalStateRecordPayloadV2; + | GoalStateRecordPayloadV2 + | TurnResultRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -598,6 +600,184 @@ 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_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; + +export interface TurnResultErrorPayload { + message: string; + code?: string; + messageTruncated?: boolean; + codeTruncated?: boolean; +} + +function readTurnResultErrorField( + error: unknown, + field: 'message' | 'code' | 'rpcCode', +): 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') ?? + readTurnResultErrorField(error, 'rpcCode'); + 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 || + payload['promptId'].length > TURN_RESULT_IDENTIFIER_MAX_CHARS || + !['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', 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', TURN_RESULT_IDENTIFIER_MAX_CHARS) || + (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 > 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') && + (fields['codeTruncated'] === undefined || + typeof fields['codeTruncated'] === 'boolean') + ); +} + export interface ChatRecordingFailureEvent { sessionId: string; error: Error; @@ -684,6 +864,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' @@ -846,7 +1028,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 { @@ -1424,6 +1610,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 }; } @@ -2451,6 +2650,32 @@ 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 { + if (!isTurnResultRecordPayload(payload)) { + debugLogger.error( + 'Skipping turn result record that violates the bounded contract:', + payload, + ); + return; + } + 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 0dd6a978434..c7827ead5f0 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3576,6 +3576,67 @@ 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, + }, + }, + { + uuid: 'artifact-after-turn-result', + parentUuid: 'turn-result-1', + sessionId: oldId, + type: 'system', + subtype: 'session_artifact_event', + timestamp: '2026-04-22T00:00:03.000Z', + cwd: lines[0]!['cwd'], + version: 'test', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: oldId, + sequence: 1, + recordedAt: '2026-04-22T00:00:03.000Z', + changes: [], + }, + }, + ] + .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(3); + expect(written.some((record) => record.subtype === 'turn_result')).toBe( + false, + ); + expect( + written.find((record) => record.uuid === 'artifact-after-turn-result'), + ).toMatchObject({ parentUuid: 'u2' }); + }); + 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 78d3ae21347..1e151a66061 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1951,6 +1951,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 f514e64ed94..5b5769ea001 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -129,6 +129,7 @@ const KNOWN_RECORD_SUBTYPES = new Set([ 'branch_checkpoint', 'goal_state', 'goal_runtime', + 'turn_result', ...ARTIFACT_RECORD_SUBTYPES, ]); diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 85b5cb59ec9..71fae2fed52 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -208,14 +208,14 @@ const projection = projectChatRecordsToDaemonTranscript(records); 包含 `WebShell` 的所有 Props,加上 Provider 配置: -| 属性 | 类型 | 说明 | -| -------------------- | --------- | ------------------------------------------------------------------------------------ | -| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` | -| `token` | `string` | daemon API Bearer token | -| `sessionId` | `string` | 要连接的 session id;未传或 `undefined` 时保持空页面 | -| `workspaceId` | `string` | 已注册工作区 id,主要用于定位已有 session;不会注册或锁定工作区 | -| `workspaceCwd` | `string` | 已注册工作区路径,语义同 `workspaceId`;不会注册或锁定工作区,且优先于 `workspaceId` | -| `lockWorkspaceCwd` | `string` | 锁定到指定工作区路径;未注册时自动持久注册,并隐藏其他工作区及添加、移除和选择入口 | +| 属性 | 类型 | 说明 | +| -------------------- | --------- | ------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` | +| `token` | `string` | daemon API Bearer token | +| `sessionId` | `string` | 要连接的 session id;未传或 `undefined` 时保持空页面 | +| `workspaceId` | `string` | 已注册工作区 id,主要用于定位已有 session;不会注册或锁定工作区 | +| `workspaceCwd` | `string` | 已注册工作区路径,语义同 `workspaceId`;不会注册或锁定工作区,且优先于 `workspaceId` | +| `lockWorkspaceCwd` | `string` | 锁定到指定工作区路径;未注册时自动持久注册,并隐藏其他工作区及添加、移除和选择入口 | | `restartSseOnPrompt` | `boolean` | 每次 prompt 被 daemon 接收后重建存活 SSE 流;流断开时提交 prompt 总会立即重建(与此开关无关);默认关闭 | ### WebShell