From 3fc1133d4441daa1a8b8a82a8c4aefff12732c54 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Tue, 15 Sep 2026 15:16:38 +0000 Subject: [PATCH] feat(daemon): support summary projection for prompt and replay APIs (#11920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 钉萁 --- docs/design/replay-summary-api.md | 93 ++++ docs/design/replay-summary-api.zh-CN.md | 73 +++ docs/developers/daemon-rest-api.openapi.json | 25 +- docs/developers/qwen-serve-protocol.md | 80 +++- .../acp-bridge/src/bridge-summary.test.ts | 427 ++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 83 +++- packages/acp-bridge/src/bridgeTypes.ts | 8 + .../acp-bridge/src/compactionEngine.test.ts | 4 +- packages/acp-bridge/src/compactionEngine.ts | 37 +- packages/acp-bridge/src/eventBus.test.ts | 67 +++ packages/acp-bridge/src/eventBus.ts | 25 +- packages/acp-bridge/src/index.ts | 2 + .../acp-bridge/src/replay-summary.test.ts | 174 +++++++ packages/acp-bridge/src/replay-summary.ts | 118 +++++ .../acp-bridge/src/transcript-replay.test.ts | 56 +++ packages/acp-bridge/src/transcript-replay.ts | 7 +- packages/cli/src/serve/acp-http/dispatch.ts | 4 +- .../cli/src/serve/acp-http/transport.test.ts | 28 ++ .../standalone-session-service.ts | 4 + .../rest-integration-docs-contract.test.ts | 31 ++ .../routes/session-prompt-terminals.test.ts | 285 ++++++++++++ packages/cli/src/serve/routes/session.ts | 57 ++- .../serve/routes/standalone-sessions.test.ts | 12 + .../src/serve/routes/standalone-sessions.ts | 14 + .../sdk-typescript/src/daemon/DaemonClient.ts | 13 + .../src/daemon/DaemonSessionClient.ts | 4 + .../src/daemon/standalone-sessions.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 2 + .../src/daemon/ui/transcript.ts | 9 +- .../test/unit/DaemonClient.test.ts | 9 +- .../test/unit/DaemonSessionClient.test.ts | 2 + .../sdk-typescript/test/unit/daemonUi.test.ts | 196 ++++---- .../components/MessageList.dom.test.tsx | 20 + .../DaemonSessionProvider.subagent.test.ts | 73 ++- .../session/DaemonSessionProvider.test.tsx | 297 ++++++------ .../daemon/session/DaemonSessionProvider.tsx | 58 ++- .../client/daemon/session/actions.test.ts | 59 +++ .../client/daemon/session/actions.ts | 12 +- 38 files changed, 2136 insertions(+), 333 deletions(-) create mode 100644 docs/design/replay-summary-api.md create mode 100644 docs/design/replay-summary-api.zh-CN.md create mode 100644 packages/acp-bridge/src/bridge-summary.test.ts create mode 100644 packages/acp-bridge/src/replay-summary.test.ts create mode 100644 packages/acp-bridge/src/replay-summary.ts diff --git a/docs/design/replay-summary-api.md b/docs/design/replay-summary-api.md new file mode 100644 index 00000000000..abef8412d37 --- /dev/null +++ b/docs/design/replay-summary-api.md @@ -0,0 +1,93 @@ +# Summary projection for daemon replay APIs + +[English](replay-summary-api.md) | [简体中文](replay-summary-api.zh-CN.md) + +## Problem and scope + +Subagent detail dominates prompt event streams and history responses even when +Web Shell only renders a top-level Agent card. This first PR adds summary +projection without changing the existing in-memory replay lifecycle. A second, +stacked PR moves saved history to JSONL pagination. + +## API behavior + +All modes default to `full`. `eventDetailMode: "summary"` on a prompt projects +events before ring-buffer retention, SSE delivery, and replay ingestion. +Filtered events do not consume SSE IDs. The selection controls the session's +events while that prompt executes; settlement resets it to `full`. Events from a +subagent that outlives its originating prompt use the mode at publication: +`full` while idle, or the active prompt's mode after another prompt starts. The +daemon does not retain a mode per originating subagent. This selection is shared +by session subscribers and cannot later recover filtered ring-buffer detail. + +`liveReplayMode: "summary"` selects the summary live journal on load. +`compactedReplayMode: "summary"` projects `compactedReplay` on load and `events` +on both transcript pagination routes. These read options do not mutate stored +history or another caller's full response. Web Shell requests all three modes, +including older-page navigation. + +Summary retains main conversation content, root tools, Agent status and final +results, and main-model usage. It removes nested subagent text, thinking, tools, +usage and progress-only frames, plus Agent prompts, embedded tool calls and +in-progress token counters. Settled Agent results retain `tokenCount` and +`executionSummary` token totals (including failure/cancellation), so turn +metrics include subagent consumption. No separate aggregate usage event is +added; existing tool-ID deduplication prevents repeated replay from adding it +twice. Task status takes precedence over tool status because a background launch +can finish its tool call while the agent still runs. Subagent details remain +available through their separate view. + +The daemon projection is a public data contract: it keeps Agent final results +but removes prompts. Web Shell applies an additional root-card projection, +which omits result text and keeps only a bounded prompt preview when the +incoming event still contains one (for example, a full-mode late event). The +preview is a description fallback, not a guarantee of the summary API. The +separate subagent detail view requests full data. These two projections need +not have identical field sets. + +In summary mode, an Agent card does not show an incremental token badge. Its +final aggregate becomes available when that Agent completes, fails or is +cancelled, potentially while the main turn is still processing. + +Mid-turn messages carry `eventDetailMode` through the daemon queue into a +promoted prompt, including attachment fallback. Messages drained by the current +turn do not change its mode. Omission means full; same-id retries while queued +or pending must match the effective mode. Mode is part of the payload identity: +accepting a retry with a different mode would acknowledge a payload the daemon +did not retain. REST idle rejection is unchanged. The daemon ACP +`session/prompt` accepts the same optional top-level extension; it is stripped +before forwarding to the child. This is shared session behavior, not a +per-subscriber rendering preference. Public OpenAPI and protocol references must +describe the load, transcript and prompt options and their errors. + +## Boundaries and tradeoffs + +This PR preserves the existing meanings: `liveJournal` is the current incomplete +turn and `compactedReplay` contains compacted completed turns or a restored +history page. Terminal events still compact the in-memory turn. It adds no +persistence UUID or ACK dependency. Existing truncation limits remain. + +Page selection still precedes summary filtering, so a requested page size can +produce fewer visible items. Counting visible replay records belongs to the +second PR. Summary reduces retained prompt detail and response size; it alone +does not eliminate long-turn truncation or compacted-history memory growth. + +## Validation and acceptance + +Verify full defaults, invalid mode rejection, summary filtering before ring +retention, contiguous SSE IDs, prompt mode reset, independent full/summary load +responses, both transcript routes, and Web Shell older-page options. Verify that +completed turns still replay from memory without a persistence ACK. Use package +unit tests plus an isolated real daemon with a mock model provider; the provider +is simulated, while daemon routing and event retention are real. + +Reviewer checks include REST prompt mode forwarding and the Agent token badge +transition above. Existing isolated-daemon verification with a simulated +provider observed summary child SSE/usage counts of 0/0 versus default-full +counts of 7/2, with equal root aggregates of 200 input / 40 output / 240 total. +Live and reconnect streams matched across 31 numbered events. Load and +transcript returned the same aggregate for each call ID while the next prompt +was active and after both prompts settled. These were complete small pages +(`hasMore=false`), not cross-page, browser, or real-model verification. Attach +the E2E report summary to the PR; raw evidence is retained under the ignored +`.qwen/issues/pr1-summary-terminal-usage.md` and its companion directories. diff --git a/docs/design/replay-summary-api.zh-CN.md b/docs/design/replay-summary-api.zh-CN.md new file mode 100644 index 00000000000..794074224b2 --- /dev/null +++ b/docs/design/replay-summary-api.zh-CN.md @@ -0,0 +1,73 @@ +# Daemon 回放接口的 summary 投影 + +[English](replay-summary-api.md) | [简体中文](replay-summary-api.zh-CN.md) + +## 问题与范围 + +Web Shell 主界面只展示顶层 Agent 卡片,但 prompt 事件流与历史响应中包含大量 +subagent 详情。第一个 PR 增加 summary 投影,保留现有内存回放生命周期。 +第二个 PR 基于它,将已保存历史改为 JSONL 分页读取。 + +## 接口行为 + +所有模式默认 `full`。prompt 的 `eventDetailMode: "summary"` 在进入 ring buffer、 +SSE 推送和回放引擎之前过滤事件。被过滤的事件不占用 SSE ID。 +模式在该 prompt 执行期间作用于会话事件,结束时恢复为 `full`。如果 subagent +在发起它的 prompt 结束后继续运行,其事件使用发布时的模式:空闲时为 `full`, +新的 prompt 开始后使用新 prompt 的模式。daemon 不按 subagent 保存发起时的模式。 +同一会话的订阅者共享这个选择,之后无法从 ring buffer 找回已过滤的详情。 + +load 的 `liveReplayMode: "summary"` 选择精简 live journal。 +`compactedReplayMode: "summary"` 精简 load 的 `compactedReplay`,以及两条 +transcript 分页接口的 `events`。这些读取选项不修改已存历史或其他调用者的 full 响应。 +Web Shell 使用这三个模式,包括向前加载历史的导航请求。 + +summary 保留主会话内容、顶层工具、Agent 状态与最终结果、主模型 usage。 +过滤 subagent 的嵌套文本、thinking、工具、usage 和纯进度帧,并移除 Agent 的 +prompt、内嵌工具调用和执行中的 token 计数。终态 Agent 结果保留 `tokenCount` +和 `executionSummary` 的 token 汇总(包括失败、取消),供轮次统计计入 subagent +消耗。不新增独立汇总 usage 事件,沿用工具 ID 去重,避免重复回放再次累加。 +任务状态优先于工具状态,因为后台启动工具返回时,Agent 可能仍在执行。 +subagent 详情仍可通过独立视图获取。 + +daemon 投影是公开数据契约:保留 Agent 最终结果,移除 prompt。Web Shell +在此基础上再做主卡片投影,省略结果正文;如果收到的事件仍带 prompt(例如按 full +模式发布的迟到事件),则保留有长度上限的预览。预览只用于描述兜底,不是 summary +接口保证提供的字段。独立 subagent 详情视图请求 full 数据,两层投影无需拥有相同字段集。 + +summary 模式下,Agent 卡片不显示执行中持续变化的 token 徽标;该 Agent 完成、失败 +或取消时,最终汇总才可用,此时主 turn 可能仍在处理中。 + +mid-turn 消息携带的 `eventDetailMode` 随队列保存,并传入提升后的 prompt, +包括附件回退路径。消息在当前轮被消费时,不改变当前轮模式。省略表示 full; +消息仍在队列或提升后的 prompt 尚未结束时,同 ID 重试也必须匹配有效模式。 +模式属于请求内容身份的一部分:接受不同模式的重试,等于确认了一份 daemon +实际上没有保存的请求内容。 +REST 空闲拒绝语义保持不变。daemon ACP 的 `session/prompt` 接受相同的顶层可选扩展, +转发给子进程前移除。它影响会话共享事件流,不是单个订阅者的渲染偏好。 +公开 OpenAPI 和协议参考应同步描述 load、transcript、prompt 的选项和错误。 + +## 边界与取舍 + +本 PR 保持现有含义:`liveJournal` 是当前未结束轮次,`compactedReplay` 是内存中 +压缩后的已结束轮次,或恢复时读取的历史页。终止事件仍会触发内存轮次压缩。 +不增加持久化 UUID 或 ACK 依赖,现有裁剪限制保持不变。 + +选页仍发生在 summary 过滤之前,因此返回的可见内容可能少于请求页大小。 +按可见回放记录计数留在第二个 PR。summary 减少 prompt 详情的内存保留量与响应体积, +但单独不能消除长轮次裁剪或已完成历史的内存增长。 + +## 验证与验收 + +验证默认 full、非法模式拒绝、入 ring 前过滤、SSE ID 连续、prompt 模式复位、 +full/summary load 响应相互独立、两条 transcript 路由,以及 Web Shell 更早分页参数。 +确认不依赖持久化 ACK,已完成轮次仍可从内存回放。使用包内单测和隔离的真实 daemon, +模型提供方使用模拟服务;模拟的是模型响应,daemon 路由和事件保留逻辑是真实实现。 + +评审验收包括 REST prompt 模式转发和上述 Agent token 徽标变化。既有隔离 daemon +验证使用模拟 provider:summary 的子事件/usage 数为 0/0,默认 full 为 7/2, +两者根卡片汇总均为输入 200 / 输出 40 / 总量 240。实时流与重连流的 31 个编号事件 +完全一致。在下一轮执行中及两轮均结束后,load 和 transcript 对同一 call ID 返回 +相同汇总。测试页均为完整小页(`hasMore=false`),未验证跨页、浏览器或真实模型。 +提交 PR 时附 E2E 报告摘要;原始证据保存在被忽略的 +`.qwen/issues/pr1-summary-terminal-usage.md` 及配套目录中。 diff --git a/docs/developers/daemon-rest-api.openapi.json b/docs/developers/daemon-rest-api.openapi.json index 4340094f0b0..88257908a4b 100644 --- a/docs/developers/daemon-rest-api.openapi.json +++ b/docs/developers/daemon-rest-api.openapi.json @@ -1537,6 +1537,17 @@ "type": "string" }, "description": "Frozen transcript snapshot the anchors read from. Required when atRecordId is present; otherwise may only accompany beforeRecordId. Cannot be combined with direction or cursor." + }, + { + "name": "compactedReplayMode", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["full", "summary"], + "default": "full" + }, + "description": "Project returned events after page selection. Summary removes nested subagent detail without changing cursor selection or stored history. Also supported by the workspace-qualified transcript route." } ], "responses": { @@ -3280,13 +3291,19 @@ }, "sourceId": { "type": "string" + }, + "compactedReplayMode": { + "type": "string", + "enum": ["full", "summary"], + "default": "full", + "description": "Load-only projection of compactedReplay. Does not mutate retained history. Summary filtering follows page selection, so a page may contain fewer visible items." } }, "additionalProperties": false }, "ResumeSessionRequest": { "type": "object", - "description": "Resume request. historyPageSize is not parsed on this route, and liveReplayMode is not passed to the bridge resume call (only the legacy-standalone compatibility restore sees it), so neither field is published here.", + "description": "Resume request. historyPageSize is not parsed. liveReplayMode and compactedReplayMode are validated but omitted from the ordinary bridge resume call; only legacy-standalone compatibility restore receives them. These load-only fields are not published here.", "properties": { "cwd": { "type": "string" @@ -3787,6 +3804,12 @@ "_meta": { "type": ["object", "null"], "additionalProperties": true + }, + "eventDetailMode": { + "type": "string", + "enum": ["full", "summary"], + "default": "full", + "description": "Per-prompt event projection before ring retention and SSE delivery. Shared by session subscribers; settlement resets full. Filtered detail cannot be recovered from the ring." } }, "additionalProperties": true diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index bf0e65795a3..fbfe3a4c4be 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2320,20 +2320,20 @@ from the selected session's own MCP manager remain available. When `/capabilities.features` contains `standalone_sessions_v1`, the daemon exposes a process-global route family for top-level standalone sessions owned by its dedicated Conversations runtime. These routes never accept a workspace selector and never fall back to the primary workspace. Direct embeds that cannot construct the complete Conversations ownership, runtime, directory, lifecycle, and deletion-journal dependency graph omit both the feature and all routes below. -| Route | Request | Success | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST /standalone/sessions` | `{ "sessionId": "", "modelServiceId"?: string, "approvalMode"?: ApprovalMode }` | `200` with the standalone session, `context: { "kind": "standalone" }`, and its managed projectless output directory. Creation is prompt-less. | -| `GET /standalone/session-options` | none; any query field is rejected with 400 | `200` with `{ v, initialized, current?, approvalMode?, providers, errors? }`; the internal workspace path and ACP-channel state are omitted | -| `GET /standalone/sessions` | Query: `cursor?`, `size?` (1-100), `archiveState?` (`active` or `archived`) | `200 { sessions, nextCursor?, liveMergeFailed?, truncated? }` | -| `GET /standalone/sessions/:id` | none | `202 { sessionId, state: "creating" }` while local creation is in flight, otherwise `200` with the exact summary. | -| `POST /standalone/sessions/:id/load` | Existing restore options only: `historyPageSize?`, `liveReplayMode?`, `hideInheritedHistory?`, `approvalMode?`; client identity stays in `X-Qwen-Client-Id`. | `200` restored standalone session. | -| `POST /standalone/sessions/:id/resume` | Same restore options as `load`. | `200` restored standalone session without load-history replay. | -| `POST /standalone/sessions/:id/repair-directory` | Empty body or `{}` | `200` with the verified or recreated managed directory. | -| `PATCH /standalone/sessions/:id/metadata` | `{ "displayName": string }` | `200 { sessionId, displayName }` | -| `GET /standalone/sessions/:id/export` | Query: `format=html`, `format=md`, `format=json`, or `format=jsonl` (defaults to `html`). | Existing export content type, filename, and body. | -| `POST /standalone/sessions/archive` | `{ "sessionIds": ["", ...] }` | `200 { archived, alreadyArchived, notFound, errors }` | -| `POST /standalone/sessions/unarchive` | `{ "sessionIds": ["", ...] }` | `200 { unarchived, alreadyActive, notFound, errors }` | -| `POST /standalone/sessions/delete` | `{ "sessionIds": ["", ...] }` | `200 { removed, notFound, errors, fileCleanupPending }` | +| Route | Request | Success | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /standalone/sessions` | `{ "sessionId": "", "modelServiceId"?: string, "approvalMode"?: ApprovalMode }` | `200` with the standalone session, `context: { "kind": "standalone" }`, and its managed projectless output directory. Creation is prompt-less. | +| `GET /standalone/session-options` | none; any query field is rejected with 400 | `200` with `{ v, initialized, current?, approvalMode?, providers, errors? }`; the internal workspace path and ACP-channel state are omitted | +| `GET /standalone/sessions` | Query: `cursor?`, `size?` (1-100), `archiveState?` (`active` or `archived`) | `200 { sessions, nextCursor?, liveMergeFailed?, truncated? }` | +| `GET /standalone/sessions/:id` | none | `202 { sessionId, state: "creating" }` while local creation is in flight, otherwise `200` with the exact summary. | +| `POST /standalone/sessions/:id/load` | Existing restore options only: `historyPageSize?`, `liveReplayMode?`, `compactedReplayMode?`, `hideInheritedHistory?`, `approvalMode?`; client identity stays in `X-Qwen-Client-Id`. | `200` restored standalone session. | +| `POST /standalone/sessions/:id/resume` | Same restore options as `load`. | `200` restored standalone session without load-history replay. | +| `POST /standalone/sessions/:id/repair-directory` | Empty body or `{}` | `200` with the verified or recreated managed directory. | +| `PATCH /standalone/sessions/:id/metadata` | `{ "displayName": string }` | `200 { sessionId, displayName }` | +| `GET /standalone/sessions/:id/export` | Query: `format=html`, `format=md`, `format=json`, or `format=jsonl` (defaults to `html`). | Existing export content type, filename, and body. | +| `POST /standalone/sessions/archive` | `{ "sessionIds": ["", ...] }` | `200 { archived, alreadyArchived, notFound, errors }` | +| `POST /standalone/sessions/unarchive` | `{ "sessionIds": ["", ...] }` | `200 { unarchived, alreadyActive, notFound, errors }` | +| `POST /standalone/sessions/delete` | `{ "sessionIds": ["", ...] }` | `200 { removed, notFound, errors, fileCleanupPending }` | When `POST /standalone/sessions` carries `modelServiceId`, the response includes `modelApplied`: `false` means the spawn-time model switch failed (also surfaced via the `model_switch_failed` session event) and the session is running on the agent default model — the create itself still succeeds so the caller can warn, release, or retry explicitly. @@ -2463,6 +2463,13 @@ An ACP-created session that never receives a prompt leaves no persisted trace, a ### `POST /session/:id/load` +`liveReplayMode` and `compactedReplayMode` accept `full` (default) or `summary`. +They project `liveJournal` and `compactedReplay` respectively without changing +stored history. Page selection precedes filtering, so visible items may be fewer +than `historyPageSize`. Invalid values return `400 invalid_live_replay_mode` or +`400 invalid_compacted_replay_mode`. Standalone load accepts both options but +reports invalid values as `400 invalid_request`. + Restore a persisted ACP session by id and replay its history through SSE. The path id is authoritative; any `sessionId` field in the body is ignored. Pre-flight `caps.features.session_load` — older daemons return `404` for this route. Request: @@ -2523,12 +2530,13 @@ Return one page of id-less `session_update` replay frames reconstructed from the Query parameters: -| Field | Required | Notes | -| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cursor` | no | Opaque base64url cursor returned by the previous page. Omit for the first page. The cursor is daemon-issued and tamper-checked; modifying it returns `400 invalid_transcript_cursor`. It binds to the transcript file identity and frozen first-page byte size; deleting, truncating, replacing, or archiving the file invalidates it and returns `409`. | -| `limit` | no | Target number of active `ChatRecord`s in a page. Defaults to `100`, maximum `500`. A backward page may expand to at most `3 * limit` records to preserve turn and tool-call/result boundaries. One record can produce multiple replay frames, so `events.length` may be larger still. Invalid values return `400 invalid_transcript_limit`. | -| `direction` | no | The only accepted value is `backward`, which starts a page at the newest records. Omit the parameter to page forward on a first page, or to continue the direction frozen in `cursor`. Cannot be combined with `cursor`, `beforeRecordId`, `atRecordId`, or `snapshot`. | -| `beforeRecordId` | no | Start a backward page before this record id. Implies backward, so do not also send `direction`. Cannot be combined with `cursor` or `atRecordId`; may be paired with `snapshot`. | +| Field | Required | Notes | +| --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cursor` | no | Opaque base64url cursor returned by the previous page. Omit for the first page. The cursor is daemon-issued and tamper-checked; modifying it returns `400 invalid_transcript_cursor`. It binds to the transcript file identity and frozen first-page byte size; deleting, truncating, replacing, or archiving the file invalidates it and returns `409`. | +| `limit` | no | Target number of active `ChatRecord`s in a page. Defaults to `100`, maximum `500`. A backward page may expand to at most `3 * limit` records to preserve turn and tool-call/result boundaries. One record can produce multiple replay frames, so `events.length` may be larger still. Invalid values return `400 invalid_transcript_limit`. | +| `direction` | no | The only accepted value is `backward`, which starts a page at the newest records. Omit the parameter to page forward on a first page, or to continue the direction frozen in `cursor`. Cannot be combined with `cursor`, `beforeRecordId`, `atRecordId`, or `snapshot`. | +| `beforeRecordId` | no | Start a backward page before this record id. Implies backward, so do not also send `direction`. Cannot be combined with `cursor` or `atRecordId`; may be paired with `snapshot`. | +| `compactedReplayMode` | no | `full` (default) or `summary`. Projects `events` after page selection, so visible count may be smaller than `limit`. Does not change stored history or cursor selection. Also accepted by the workspace-qualified transcript route. Invalid values return `400 invalid_compacted_replay_mode`. | Response: @@ -2620,9 +2628,10 @@ Restore a persisted ACP session by id WITHOUT replaying history through SSE. The Accepts the same `cwd`, `approvalMode`, `sourceType`, and `sourceId` fields as `/load`. `historyPageSize` is not parsed here and is silently ignored. -`liveReplayMode` is parsed and validated — an invalid value returns -`400 invalid_live_replay_mode` — but only the legacy-standalone compatibility -restore forwards it; the ordinary resume path drops it. Neither field is part +`liveReplayMode` and `compactedReplayMode` are parsed and validated — invalid +values return `400 invalid_live_replay_mode` or `400 invalid_compacted_replay_mode` +— but only the legacy-standalone compatibility restore forwards them; ordinary +resume drops them. None of these load-only fields is part of the published resume request. Same response shape — `state` mirrors ACP's `ResumeSessionResponse`. Same error envelope, including `409 restore_in_progress` (which fires when a `session/load` is in flight; @@ -2931,6 +2940,15 @@ This rule is live-session-only and does not make every workspace-less session ro `POST /session/:id/mid-turn-message` accepts `{ "message": "...", "messageId": "" }`. A successful admission returns `{ "accepted": true, "messageId": "" }` and transfers ownership to the daemon: the message is drained into the active turn or promoted into the normal prompt FIFO when the session becomes idle. Clients using `session_mid_turn_message_query` send a stable `messageId`; repeating it is idempotent while it remains queued, pending, or in the bounded reconciliation rings. A rejection of a request the daemon validated returns `{ "accepted": false }` and never transfers ownership: an open session with no prompt admitted to its prompt FIFO and no active Goal turn reports `{ "accepted": false, "reason": "session_idle" }` so the client can resubmit the message as an ordinary prompt instead of surfacing a failure. For a genuinely new admission, a `content` block referencing an attachment the session no longer holds — or an invalid reference — is declined before the idle and queue verdicts: the request is answered `410 session_attachment_gone` (or `400 invalid_session_attachment_reference`) with an `{ "error", "code" }` body, which carries no `reason` even when the session is idle. Two earlier gates preempt it: a repeated `messageId` whose payload still matches settles idempotently with `{ "accepted": true, "messageId" }` even if the attachment has since been removed, and a session that is closing or authorizing a close is refused with a reasonless `{ "accepted": false }`. The verdict describes only what can drain a mid-turn message, not everything the session may hold — a session snapshot can still report `hasActivePrompt: true` for it, for example while a deferred restore prompt is parked. A reasonless rejection has another cause — the queue is full, the session is closing or authorizing a close, the queued inline-attachment budget is exhausted, or a repeated `messageId` no longer matches the payload the daemon holds. The daemon keeps the payload it already admitted rather than replacing it, but that is not a delivery promise: a promoted message the client removed disappears from pending-prompt snapshots at once — one that had not started is dropped where it stands, one already running is hidden until its aborted turn settles — so the removal response is the only `removed = true` a client ever observes. A closing session never promotes what remains queued, and a turn that settles while a close is still being authorized hands the queue to the admission gate, which refuses it, so those messages are dropped rather than promoted and land in no ring. A close the child then refuses leaves the session live with whatever remained queued still listed and nothing scheduled to promote it until some later turn ends. Those messages are still the daemon's, so the default recovery is to wait: the next turn's settle promotes the backlog, nothing is lost and nothing is delivered twice. A client that cannot wait — no further turn is coming and the user is watching a queue that will not drain — may force the drain instead, and the only supported way is to release each payload before sending it again. Both paths start from the same set: build it from the client's own record of what it enqueued during that window minus whatever it has positive delivery evidence for, not from the listing's silence. The query is not an inventory of what the daemon holds — it omits a prompt already marked removed, both rings are bounded, and an enqueue sent without a client id is not tracked on this surface at all — so an id reported nowhere may have aged out after delivery, been dropped undelivered, or never been listed. Positive delivery evidence is a `mid_turn_message_injected` echo, a `pending_prompt_started` for that id, or membership in either ring. +The mid-turn POST also accepts optional `eventDetailMode`: `full` (default) or +`summary`; invalid values return `400 invalid_event_detail_mode`. The daemon +retains this mode with the message and uses it if an undrained message becomes +a new prompt. Draining into an existing turn does not change that turn's mode. +While queued or promoted-and-pending, a retry under the same `messageId` must +match text, media and effective mode (omitted equals full). Web Shell sends its +current Provider mode. The REST route still rejects new idle admissions with +`reason: "session_idle"`; callers then submit an ordinary prompt. + To force the drain, `DELETE /session/:id/mid-turn-messages/:messageId` every id in that set which the query still reports as queued, and only then post the text again under a new `messageId`, because a repeated id is acked idempotently and re-arms nothing. The order matters and applies to the whole set rather than one id at a time: a still-queued payload the daemon owns is promoted by the next settle, so re-posting an id whose payload was never released delivers that message twice — once from that promotion and once from the resend. Where `session_mid_turn_message_mutation` is not advertised there is no way to release a payload, so waiting is the only safe option. No DELETE verdict establishes delivery on its own. `{ "removed": false }` covers an id already injected or completed — delivered, so it must not be re-posted — and, per the DELETE route below, an id not found, which for one the query had just listed as queued means it left the queue between the two calls and may have been dropped undelivered by the close-authorization path above; decide that case from the client's own evidence. `{ "removed": true }` releases the payload, but for an id a settle promoted in that window it covers both a FIFO entry spliced before it ever dispatched, which is safe to re-post, and an abort of a promoted prompt that had already started, which a re-post then sends a second time. A promoted id also stays deletable through the same route until it settles, even though the listing no longer reports it as queued. The re-post itself is a mid-turn admission and can be refused `{ "accepted": false, "reason": "session_idle" }`; that is not a failure but the signal described above that the session now takes an ordinary prompt, and submitting through the ordinary route starts a turn whose settle promotes whatever is still queued — which is the same reason every id in the set has to be released first. A missing `reason` is therefore not evidence of a busy session. Daemons that predate `reason` omit it in every case, so clients keep their own idle detection alongside it. New clients connected to an older daemon detect the missing capability and retain their legacy local fallback. @@ -2972,6 +2990,22 @@ capability tag; older daemons return `404`. The TypeScript SDK method is ### `POST /session/:id/prompt` +`eventDetailMode` accepts `full` (default) or `summary`; invalid values return +`400 invalid_event_detail_mode`. Summary filters nested subagent details before +ring retention and SSE delivery, and removes Agent prompts, embedded tools and +in-progress token counters while preserving root content and main-model usage. +Settled Agent results retain `tokenCount` and `executionSummary` token totals +(including failure/cancellation) for turn metrics; nested usage frames remain +filtered. The same rule applies to summary load/transcript responses. The mode is +captured at admission, applied only at dispatch, and resets to full on settlement. +Late subagent events use the mode at publication: full while idle, or the active +prompt's mode when a subsequent prompt is running. They do not inherit the mode +of the prompt that originally launched the subagent. +It applies to all subscribers of the session; filtered details cannot later be +retrieved from the ring. The daemon's `/acp` `session/prompt` accepts the same +optional top-level extension with the same scope (invalid values are JSON-RPC +`-32602`); it is not a standard ACP field and is not forwarded to the child. + Forward a prompt to the agent. Multi-prompt callers FIFO-queue per session (ACP guarantees one active prompt per session). Request: diff --git a/packages/acp-bridge/src/bridge-summary.test.ts b/packages/acp-bridge/src/bridge-summary.test.ts new file mode 100644 index 00000000000..d772aede2cd --- /dev/null +++ b/packages/acp-bridge/src/bridge-summary.test.ts @@ -0,0 +1,427 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { makeBridge, makeChannel, WS_A } from './internal/testUtils.js'; +import type { BridgeEvent } from './eventBus.js'; + +describe('summary API without persistence acknowledgements', () => { + it('projects cold and attached load responses without modifying full history', async () => { + const updates = [ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'main prompt' }, + }, + { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'nested detail' }, + _meta: { parentToolCallId: 'agent-1' }, + }, + { + sessionUpdate: 'tool_call', + toolCallId: 'agent-1', + title: 'Agent', + kind: 'other', + status: 'completed', + rawOutput: { + type: 'task_execution', + result: 'final answer', + tokenCount: 123, + toolCalls: ['nested-tool'], + }, + }, + ]; + const original = structuredClone(updates); + const handle = makeChannel({ + loadSessionImpl: () => ({ + _meta: { 'qwen.session.loadReplay': { v: 1, updates } }, + }), + extMethodImpl: (_method, params) => ({ + v: 1, + sessionId: params['sessionId'], + events: updates.map((data) => ({ v: 1, type: 'session_update', data })), + hasMore: false, + }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + try { + const request = { + sessionId: 'summary-api', + workspaceCwd: WS_A, + historyReplay: 'response' as const, + historyPageSize: 200, + }; + for (const mode of ['summary', 'full', 'summary'] as const) { + const loaded = await bridge.loadSession({ + ...request, + compactedReplayMode: mode, + }); + const replay = JSON.stringify(loaded.compactedReplay); + expect(replay).toContain('main prompt'); + expect(replay).toContain('final answer'); + expect(replay.includes('nested detail')).toBe(mode === 'full'); + expect(replay).toContain('tokenCount'); + } + expect(updates).toEqual(original); + } finally { + await bridge.shutdown(); + } + }); + + it('rejects invalid modes before channel creation or prompt admission', async () => { + const channelFactory = vi.fn(async () => makeChannel().channel); + const bridge = makeBridge({ channelFactory }); + try { + await expect( + bridge.loadSession({ + sessionId: 'invalid', + workspaceCwd: WS_A, + compactedReplayMode: 'invalid' as never, + }), + ).rejects.toMatchObject({ code: -32602 }); + expect(() => + bridge.sendPrompt('invalid', { + sessionId: 'invalid', + prompt: [{ type: 'text', text: 'hello' }], + eventDetailMode: 'invalid' as never, + }), + ).toThrow('Invalid eventDetailMode'); + expect(channelFactory).not.toHaveBeenCalled(); + } finally { + await bridge.shutdown(); + } + }); +}); + +function gate() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +it.each([ + ['full', true], + ['summary', true], + ['full', false], + ['summary', false], +] as const)( + 'preserves %s queued mode without changing the running prompt (mid-turn=%s)', + async (mode, midTurn) => { + const held = gate(); + const promoted = gate(); + const events: BridgeEvent[] = []; + const handle = makeChannel({ + promptImpl: async (p, agent) => { + const first = agent.promptCalls.length === 1; + await (first ? held.promise : promoted.promise); + await handle.agentConnection.sessionUpdate({ + sessionId: p.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: first ? 'first-child' : 'next-child', + }, + _meta: { parentToolCallId: 'agent' }, + }, + }); + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + let collect: Promise | undefined; + try { + const { sessionId } = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + collect = (async () => { + for await (const event of bridge.subscribeEvents(sessionId)) + events.push(event); + })(); + const first = bridge.sendPrompt(sessionId, { + sessionId, + prompt: [{ type: 'text', text: 'held' }], + eventDetailMode: mode === 'summary' ? 'full' : 'summary', + }); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + const enqueue = (eventDetailMode: 'full' | 'summary' | undefined) => + bridge.enqueueMidTurnMessage(sessionId, 'follow up', undefined, 'mid', { + eventDetailMode, + }); + const queued = midTurn + ? undefined + : bridge.sendPrompt( + sessionId, + { + sessionId, + prompt: [{ type: 'text', text: 'follow up' }], + eventDetailMode: mode, + }, + undefined, + { promptId: 'mid' }, + ); + if (midTurn) { + expect(enqueue(mode).accepted).toBe(true); + expect(enqueue(mode).accepted).toBe(true); + expect(enqueue(mode === 'summary' ? 'full' : 'summary').accepted).toBe( + false, + ); + expect(enqueue(undefined).accepted).toBe(mode === 'full'); + } + held.resolve(); + await first; + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(2)); + if (midTurn) { + expect(enqueue(mode).accepted).toBe(true); + expect(enqueue(mode === 'summary' ? 'full' : 'summary').accepted).toBe( + false, + ); + } + promoted.resolve(); + await queued; + await vi.waitFor(() => + expect( + events.some( + (e) => e.type === 'turn_complete' && e.promptId === 'mid', + ), + ).toBe(true), + ); + expect(JSON.stringify(events).includes('first-child')).toBe( + mode === 'summary', + ); + expect(JSON.stringify(events).includes('next-child')).toBe( + mode === 'full', + ); + expect( + handle.agent.promptCalls.every((p) => !('eventDetailMode' in p)), + ).toBe(true); + } finally { + held.resolve(); + promoted.resolve(); + await bridge.shutdown(); + await collect; + } + }, +); + +it.each(['idle', 'drain'] as const)( + 'handles summary mid-turn %s without changing an existing turn', + async (path) => { + const held = gate(); + const events: BridgeEvent[] = []; + const handle = makeChannel({ + promptImpl: async (p) => { + await held.promise; + await handle.agentConnection.sessionUpdate({ + sessionId: p.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'child-probe' }, + _meta: { parentToolCallId: 'agent' }, + }, + }); + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + let collect: Promise | undefined; + try { + const { sessionId } = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + collect = (async () => { + for await (const e of bridge.subscribeEvents(sessionId)) events.push(e); + })(); + const running = + path === 'drain' + ? bridge.sendPrompt(sessionId, { + sessionId, + prompt: [{ type: 'text', text: 'held' }], + }) + : undefined; + if (running) + await vi.waitFor(() => + expect(handle.agent.promptCalls).toHaveLength(1), + ); + expect( + bridge.enqueueMidTurnMessage(sessionId, 'follow up', undefined, 'mid', { + eventDetailMode: 'summary', + }).accepted, + ).toBe(true); + if (running) + await handle.agentConnection.extMethod('craft/drainMidTurnQueue', { + sessionId, + }); + held.resolve(); + await running; + await vi.waitFor(() => + expect(events.some((e) => e.type === 'turn_complete')).toBe(true), + ); + expect(handle.agent.promptCalls).toHaveLength(1); + expect(JSON.stringify(events).includes('child-probe')).toBe( + path === 'drain', + ); + } finally { + held.resolve(); + await bridge.shutdown(); + await collect; + } + }, +); + +it.each(['success', 'error', 'cancel'] as const)( + 'restores full after a summary prompt %s', + async (outcome) => { + const held = gate(); + const events: BridgeEvent[] = []; + const handle = makeChannel({ + promptImpl: async (p, agent) => { + if (agent.promptCalls.length === 1) { + await held.promise; + if (outcome === 'error') throw new Error('provider failed'); + return { + stopReason: outcome === 'cancel' ? 'cancelled' : 'end_turn', + }; + } + await handle.agentConnection.sessionUpdate({ + sessionId: p.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'default-full-child' }, + _meta: { parentToolCallId: 'agent' }, + }, + }); + return { stopReason: 'end_turn' }; + }, + cancelImpl: () => held.resolve(), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + let collect: Promise | undefined; + try { + const { sessionId } = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + collect = (async () => { + for await (const e of bridge.subscribeEvents(sessionId)) events.push(e); + })(); + const first = bridge + .sendPrompt(sessionId, { + sessionId, + prompt: [{ type: 'text', text: 'first' }], + eventDetailMode: 'summary', + }) + .catch((e: unknown) => e); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + if (outcome === 'cancel') await bridge.cancelSession(sessionId); + else held.resolve(); + const result = await first; + if (outcome === 'error') expect(result).toMatchObject({ code: -32603 }); + else + expect(result).toMatchObject({ + stopReason: outcome === 'cancel' ? 'cancelled' : 'end_turn', + }); + await handle.agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'idle-full-child' }, + _meta: { parentToolCallId: 'agent' }, + }, + }); + await vi.waitFor(() => + expect(JSON.stringify(events)).toContain('idle-full-child'), + ); + await bridge.sendPrompt(sessionId, { + sessionId, + prompt: [{ type: 'text', text: 'next' }], + }); + await vi.waitFor(() => + expect(JSON.stringify(events)).toContain('default-full-child'), + ); + } finally { + held.resolve(); + await bridge.shutdown(); + await collect; + } + }, +); + +it('reports invalid mid-turn mode in the diagnostic message', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + try { + const { sessionId } = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(() => + bridge.enqueueMidTurnMessage(sessionId, 'hello', undefined, undefined, { + eventDetailMode: 'invalid' as never, + }), + ).toThrow('Invalid eventDetailMode'); + expect(handle.agent.promptCalls).toHaveLength(0); + } finally { + await bridge.shutdown(); + } +}); + +it('projects late child events using the mode at publication, not the originating prompt', async () => { + let held = gate(); + const events: BridgeEvent[] = []; + const handle = makeChannel({ + promptImpl: async () => { + await held.promise; + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + let collect: Promise | undefined; + try { + const { sessionId } = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + collect = (async () => { + for await (const event of bridge.subscribeEvents(sessionId)) + events.push(event); + })(); + const publishLate = async (label: string) => { + await handle.agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: label }, + _meta: { parentToolCallId: 'agent-from-first-prompt' }, + }, + }); + await handle.agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `checkpoint-${label}` }, + }, + }); + await vi.waitFor(() => + expect(JSON.stringify(events)).toContain(`checkpoint-${label}`), + ); + return events.some((event) => { + const data = event.data as { update?: { content?: { text?: string } } }; + return data?.update?.content?.text === label; + }); + }; + for (const [index, mode] of ( + ['summary', 'full', 'summary'] as const + ).entries()) { + held = gate(); + const prompt = bridge.sendPrompt(sessionId, { + sessionId, + prompt: [{ type: 'text', text: `prompt-${index}` }], + eventDetailMode: mode, + }); + await vi.waitFor(() => + expect(handle.agent.promptCalls).toHaveLength(index + 1), + ); + expect(await publishLate(`late-${index}`)).toBe(mode === 'full'); + held.resolve(); + await prompt; + expect(await publishLate(`idle-${index}`)).toBe(true); + } + } finally { + held.resolve(); + await bridge.shutdown(); + await collect; + } +}); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index e92a48dde3c..61a7001e65f 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -62,6 +62,7 @@ import { DEFAULT_RING_SIZE, EVENT_SCHEMA_VERSION, type BridgeEvent, + type LiveReplayMode, } from './eventBus.js'; import { JOURNAL_GROWTH_HARD_CAP_BYTES, @@ -73,6 +74,7 @@ import { type JournalGrowthSessionLimit, } from './compactionEngine.js'; import { createJournalGrowthPolicy } from './journalGrowthPolicy.js'; +import { summarizeReplay } from './replay-summary.js'; import { BridgeChannelClosedError, BridgeTimeoutError, @@ -3750,6 +3752,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { */ function settleActivePromptState(entry: SessionEntry, promptId: string) { if (entry.activePromptId !== promptId) return; + entry.events.setEventDetailMode('full'); delete entry.activePromptId; delete entry.activePromptOriginatorClientId; if (entry.promptActive) { @@ -8120,6 +8123,37 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { deferRestorePrompt?: boolean; daemonOwnedStandaloneRestore?: boolean; } = {}, + ): Promise { + if ( + req.compactedReplayMode !== undefined && + req.compactedReplayMode !== 'full' && + req.compactedReplayMode !== 'summary' + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid compactedReplayMode; expected full or summary', + ); + } + const restored = await restoreSessionWithReplay(action, req, options); + return action === 'load' && + req.compactedReplayMode === 'summary' && + restored.compactedReplay + ? { + ...restored, + compactedReplay: summarizeReplay(restored.compactedReplay), + } + : restored; + } + + async function restoreSessionWithReplay( + action: 'load' | 'resume', + req: BridgeRestoreSessionRequest, + options: { + skipFreshSessionAdmission?: boolean; + suppressRestorePrompt?: boolean; + deferRestorePrompt?: boolean; + daemonOwnedStandaloneRestore?: boolean; + } = {}, ): Promise { if (shuttingDown) { throw new Error('AcpSessionBridge is shutting down'); @@ -9483,6 +9517,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { text: string, originatorClientId?: string, content?: readonly BridgePromptContentBlock[], + eventDetailMode?: LiveReplayMode, ) => { // Drop references that are already gone BEFORE admission: the admission // check throws on the first dead reference, and the fallback would then @@ -9518,6 +9553,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.sessionId, { sessionId: entry.sessionId, + eventDetailMode, prompt: withAttachmentDegradationMarker( text ? [{ type: 'text', text } as ContentBlock] : [], ), @@ -9531,6 +9567,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.sessionId, { sessionId: entry.sessionId, + eventDetailMode, prompt, }, undefined, @@ -9583,6 +9620,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { message.text, message.originatorClientId, message.content, + message.eventDetailMode, ); } }; @@ -10317,6 +10355,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Keep this method non-async: admission failures must throw before // HTTP routes return 202. sendPrompt(sessionId, req, signal, context) { + if ( + req.eventDetailMode !== undefined && + req.eventDetailMode !== 'full' && + req.eventDetailMode !== 'summary' + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid eventDetailMode; expected full or summary', + ); + } + const eventDetailMode = req.eventDetailMode ?? 'full'; opts.onDiagnosticLine?.( `qwen serve: bridge sendPrompt for session=${sessionId}`, 'info', @@ -10432,6 +10481,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ? '[image]' : ''); const pendingEntry: PendingPromptEntry = { + eventDetailMode, promptId, queuedAt, ...(originatorClientId !== undefined ? { originatorClientId } : {}), @@ -10690,9 +10740,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const promptRequest = (() => { const copy = { ...normalized, - } as PromptRequest & { retry?: unknown; delivery?: unknown }; + } as PromptRequest & { + retry?: unknown; + delivery?: unknown; + eventDetailMode?: LiveReplayMode; + }; delete copy.retry; delete copy.delivery; + delete copy.eventDetailMode; const meta = copy._meta && typeof copy._meta === 'object' ? { ...copy._meta } @@ -10778,6 +10833,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } return copy; })(); + entry.events.setEventDetailMode(eventDetailMode); entry.backgroundStartsSuspended = false; let resolveTerminal!: () => void; entry.activePromptTerminal = { @@ -13927,6 +13983,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry, context?.clientId, ); + const eventDetailMode = options?.eventDetailMode ?? 'full'; + if (eventDetailMode !== 'full' && eventDetailMode !== 'summary') { + throw RequestError.invalidParams( + undefined, + 'Invalid eventDetailMode; expected full or summary', + ); + } const trimmed = message.trim(); // Attachment blocks travel with the message through drain and promotion; // text blocks are dropped so the drain never duplicates the message text. @@ -13951,11 +14014,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const sameMedia = JSON.stringify(existing.content ?? []) === JSON.stringify(mediaBlocks); - if (existing.text === trimmed && sameMedia) { + if ( + existing.text === trimmed && + sameMedia && + (existing.eventDetailMode ?? 'full') === eventDetailMode + ) { return { accepted: true, messageId: requestedMessageId }; } writeStderrLine( - `[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected id ${JSON.stringify(requestedMessageId)}: text or content mismatch`, + `[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected id ${JSON.stringify(requestedMessageId)}: text, content or mode mismatch`, ); return { accepted: false }; } @@ -13972,11 +14039,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { promoted.text === '[image]' && trimmed.length === 0 ? '' : promoted.text; - if (promotedText === trimmed && sameMedia) { + if ( + promotedText === trimmed && + sameMedia && + (promoted.eventDetailMode ?? 'full') === eventDetailMode + ) { return { accepted: true, messageId: requestedMessageId }; } writeStderrLine( - `[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected promoted id ${JSON.stringify(requestedMessageId)}: text or content mismatch`, + `[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected promoted id ${JSON.stringify(requestedMessageId)}: text, content or mode mismatch`, ); return { accepted: false }; } @@ -14051,6 +14122,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { trimmed, originatorClientId, mediaBlocks.length > 0 ? mediaBlocks : undefined, + eventDetailMode, ); return { accepted: true, messageId }; } @@ -14062,6 +14134,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { accepted: false }; } const queuedMessage: MidTurnQueueEntry = { + eventDetailMode, messageId, text: trimmed, ...(mediaBlocks.length > 0 ? { content: mediaBlocks } : {}), diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index ed647736744..923ef214688 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -117,6 +117,8 @@ export type BridgePromptContentBlock = export type BridgePromptRequest = Omit & { prompt: BridgePromptContentBlock[]; + /** Per-prompt projection before ring retention and fan-out; defaults to full. */ + eventDetailMode?: LiveReplayMode; }; export interface RewindRequest { @@ -305,6 +307,8 @@ export interface BridgeRestoreSessionRequest { historyPageSize?: number; /** Load-only live-turn replay projection; defaults to the complete journal. */ liveReplayMode?: LiveReplayMode; + /** Load response projection for durable replay; defaults to full. */ + compactedReplayMode?: LiveReplayMode; /** Keep inherited fork records as model context without replaying them. */ hideInheritedHistory?: boolean; approvalMode?: ApprovalMode; @@ -1230,6 +1234,7 @@ export type ClientMcpOverWsRuntimeConfig = Record & { /** One daemon-owned, session-global queued mid-turn message. */ export interface MidTurnQueueEntry { + eventDetailMode?: LiveReplayMode; messageId: string; text: string; /** @@ -1262,6 +1267,7 @@ export interface BridgeMidTurnMessagesSnapshot { * `removePendingPrompt` can cancel a queued-but-not-yet-started prompt. */ export interface PendingPromptEntry { + eventDetailMode?: LiveReplayMode; promptId: string; queuedAt: number; startedAt?: number; @@ -2382,6 +2388,8 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { rejectIfIdle?: boolean; queueOnly?: boolean; onSettledWithoutDrain?: () => void; + /** Applied only if the message is promoted into a new prompt. */ + eventDetailMode?: LiveReplayMode; content?: readonly BridgePromptContentBlock[]; }, ): { accepted: boolean; messageId?: string; reason?: 'session_idle' }; diff --git a/packages/acp-bridge/src/compactionEngine.test.ts b/packages/acp-bridge/src/compactionEngine.test.ts index 85e508f825f..fa934176a4e 100644 --- a/packages/acp-bridge/src/compactionEngine.test.ts +++ b/packages/acp-bridge/src/compactionEngine.test.ts @@ -1161,7 +1161,7 @@ describe('TurnBoundaryCompactionEngine', () => { ); }); - it('retains nested usage frames in the summary journal', () => { + it('omits nested usage frames from the summary journal', () => { const engine = new TurnBoundaryCompactionEngine(); const usage = makeTextChunkWithParent(1, '', 'agent-1'); ( @@ -1172,7 +1172,7 @@ describe('TurnBoundaryCompactionEngine', () => { expect( engine.snapshot('summary').liveJournal.map((event) => event.id), - ).toEqual([1]); + ).toEqual([]); }); it('excludes parented tool frames from the summary journal under cap pressure', () => { diff --git a/packages/acp-bridge/src/compactionEngine.ts b/packages/acp-bridge/src/compactionEngine.ts index 8025f1ab985..3b50e20abef 100644 --- a/packages/acp-bridge/src/compactionEngine.ts +++ b/packages/acp-bridge/src/compactionEngine.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + isSummaryReplayEvent, + summarizeReplayEvent, +} from './replay-summary.js'; import { EVENT_SCHEMA_VERSION, logEventSizingFailed, @@ -148,7 +152,7 @@ function lastRecordIdIn(events: BridgeEvent[]): string | undefined { function lastSummaryRecordIdIn(events: BridgeEvent[]): string | undefined { for (let i = events.length - 1; i >= 0; i--) { - if (!isSummaryLiveJournalEvent(events[i]!)) continue; + if (!isSummaryReplayEvent(events[i]!)) continue; const id = replayRecordId(events[i]!); if (id !== undefined) return id; } @@ -319,7 +323,7 @@ export class TurnBoundaryCompactionEngine implements CompactionEngine { // are sparse (only stamped on session_updates at turn boundaries), // so `replayRecordId` returning undefined is the common case and // intentionally leaves `activeRecordId` untouched. - const summaryEvent = isSummaryLiveJournalEvent(event); + const summaryEvent = summarizeReplayEvent(event); const seenRecordId = replayRecordId(event); if (seenRecordId !== undefined) { this.activeRecordId = seenRecordId; @@ -328,7 +332,11 @@ export class TurnBoundaryCompactionEngine implements CompactionEngine { this.appendLiveJournal(this.fullJournal, event, byteLength); if (summaryEvent) { - this.appendLiveJournal(this.summaryJournal, event, byteLength); + this.appendLiveJournal( + this.summaryJournal, + summaryEvent, + summaryEvent === event ? byteLength : undefined, + ); } if (TURN_BOUNDARY_TYPES.has(event.type)) { @@ -1198,29 +1206,6 @@ function extractParentToolCallIdFromMeta(meta: unknown): string | undefined { return undefined; } -function isSummaryLiveJournalEvent(event: BridgeEvent): boolean { - if (event.type !== 'session_update') return true; - const data = event.data as SessionUpdateData | undefined; - const meta = data?.update?._meta; - const parentToolCallId = extractParentToolCallIdFromMeta(meta); - if (parentToolCallId === undefined) return true; - // Mirror the UI normalizer's self-reference guard - // (normalizeToolUpdate drops parentToolCallId === toolCallId): such a - // frame renders as a ROOT tool block in the main transcript, so the - // summary journal must retain it too or a refresh drops a block the - // user was just looking at. - if (parentToolCallId === data?.update?.toolCallId) return true; - if (data?.update?.sessionUpdate !== 'agent_message_chunk') return false; - if (typeof meta !== 'object' || meta === null) return false; - const usage = (meta as Record)['usage']; - if (typeof usage !== 'object' || usage === null) return false; - const fields = usage as Record; - return ( - typeof fields['inputTokens'] === 'number' || - typeof fields['outputTokens'] === 'number' - ); -} - function extractSourceRecordIdsFromMeta( meta: unknown, ): readonly string[] | undefined { diff --git a/packages/acp-bridge/src/eventBus.test.ts b/packages/acp-bridge/src/eventBus.test.ts index a06a812ac07..eb5a8417b61 100644 --- a/packages/acp-bridge/src/eventBus.test.ts +++ b/packages/acp-bridge/src/eventBus.test.ts @@ -45,6 +45,73 @@ describe('EventBus', () => { expect(bus.lastEventId).toBe(2); }); + it('projects before ring retention and fanout without consuming filtered IDs', async () => { + const bus = new EventBus(); + const iter = bus.subscribe()[Symbol.asyncIterator](); + bus.setEventDetailMode('summary'); + const nested = { + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'private detail' }, + _meta: { parentToolCallId: 'agent-1' }, + }, + }, + }; + expect(bus.publish(nested)).toBeUndefined(); + const childUsage = { + ...nested, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + parentToolCallId: 'agent-1', + usage: { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }; + expect(bus.publish(childUsage)).toBeUndefined(); + expect(bus.lastEventId).toBe(0); + const result = bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'completed', + rawOutput: { + type: 'task_execution', + toolCalls: ['detail'], + taskPrompt: 'prompt', + result: 'answer', + tokenCount: 2, + executionSummary: { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }); + expect(result?.id).toBe(1); + expect(result).toHaveProperty('data.update.rawOutput.executionSummary', { + inputTokens: 10, + outputTokens: 2, + }); + expect(result).toHaveProperty('data.update.rawOutput.tokenCount', 2); + expect((await iter.next()).value).toEqual(result); + expect(JSON.stringify(result)).not.toContain('toolCalls'); + bus.setEventDetailMode('full'); + expect(bus.publish(nested)?.id).toBe(2); + expect((await iter.next()).value?.data).toEqual(nested.data); + const replay = await collect(bus.subscribe({ lastEventId: 0 }), 3); + expect(replay.slice(0, 2).map((e) => e.id)).toEqual([1, 2]); + expect(replay[0]).toEqual(result); + expect(replay[2]?.type).toBe('replay_complete'); + await iter.return?.(); + bus.close(); + }); + it('rejects invalid maxQueuedBytes options', () => { expect( () => new EventBus(100, undefined, undefined, { maxQueuedBytes: 0 }), diff --git a/packages/acp-bridge/src/eventBus.ts b/packages/acp-bridge/src/eventBus.ts index 520d63dacc3..71d19b0fb24 100644 --- a/packages/acp-bridge/src/eventBus.ts +++ b/packages/acp-bridge/src/eventBus.ts @@ -20,6 +20,7 @@ */ import { randomUUID } from 'node:crypto'; +import { summarizeReplayEvent } from './replay-summary.js'; export interface SessionReplaySnapshot { compactedTurns: BridgeEvent[]; @@ -495,14 +496,21 @@ export class EventBus { return events; } + private eventDetailMode: LiveReplayMode = 'full'; + + setEventDetailMode(mode: LiveReplayMode): void { + this.eventDetailMode = mode; + } + /** * Publish an event to the bus. Returns the constructed `BridgeEvent` - * (with `id` + `v` assigned) on success, or `undefined` when the - * bus is closed. + * (with `id` + `v` assigned) on success, or `undefined` when the bus + * is closed, the event is unserializable, or summary mode filters it. + * Filtering is a normal no-op; rejected events do not consume an ID. * - * **Never throws** (never-throws contract). Closing the bus mid-publish - * is the only abnormal path and is handled as a return-undefined - * no-op; subscriber-enqueue failures are caught internally and + * **Never throws** (never-throws contract). Closed-bus and unserializable + * events are handled as return-undefined no-ops; + * subscriber-enqueue failures are caught internally and * translated to per-subscriber eviction. Call sites can rely on * this — the historical `try { publish(...) } catch {}` blocks in * `httpAcpBridge.ts` are defense-in-depth, not load-bearing, and @@ -523,7 +531,7 @@ export class EventBus { // to anyway. if (this.closed) return undefined; const existingMeta = input._meta; - const event: BridgeEvent = { + let event: BridgeEvent = { // Read WITHOUT incrementing: a rejected event must not burn an id — // other subscribers would see a sequence gap (3 → 5) that resume // logic misreads as ring eviction. `nextId` advances only after the @@ -536,6 +544,11 @@ export class EventBus { serverTimestamp: getServerTimestamp(existingMeta), }, }; + if (this.eventDetailMode === 'summary') { + const projected = summarizeReplayEvent(event); + if (!projected) return undefined; + event = projected; + } // Eager sizing doubles as the serializability gate (DAEMON-011): an // event JSON.stringify cannot represent would bypass every byte cap // at weight 0 and then fail at SSE send time anyway. Reject it here — diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index fab71a279a0..b33b5475e39 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -27,3 +27,5 @@ export * from './bridgeClient.js'; export * from './bridge.js'; export * from './bridgeFileSystem.js'; export * from './generation-stream.js'; + +export { summarizeReplay } from './replay-summary.js'; diff --git a/packages/acp-bridge/src/replay-summary.test.ts b/packages/acp-bridge/src/replay-summary.test.ts new file mode 100644 index 00000000000..ebb549c6f32 --- /dev/null +++ b/packages/acp-bridge/src/replay-summary.test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { summarizeReplay } from './replay-summary.js'; +import type { BridgeEvent } from './eventBus.js'; + +const frame = (id: number, update: Record): BridgeEvent => ({ + id, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { sessionId: 'session-1', update }, +}); + +describe('summary replay projection', () => { + it('preserves root results, attribution, main usage and interactions without mutating full replay', () => { + const result = frame(4, { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'completed', + _meta: { toolName: 'agent', 'qwen.session.recordId': 'record-1' }, + rawInput: { description: 'Review', prompt: 'long task prompt' }, + rawOutput: { + type: 'task_execution', + status: 'completed', + result: 'final answer', + tokenCount: 12, + subagentSessionReady: true, + taskPrompt: 'long task prompt', + toolCalls: [{ output: 'nested detail' }], + executionSummary: { + totalToolCalls: 1, + totalDurationMs: 200, + inputTokens: 10, + outputTokens: 2, + thoughtTokens: 1, + cachedTokens: 5, + totalTokens: 12, + }, + }, + }); + const nested = frame(1, { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thinking' }, + _meta: { parentToolCallId: 'agent-1' }, + }); + const usage = frame(2, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'nested answer' }, + _meta: { + parentToolCallId: 'agent-1', + usage: { inputTokens: 10, outputTokens: 2 }, + }, + }); + const childTool = frame(3, { + sessionUpdate: 'tool_call', + toolCallId: 'child-1', + _meta: { parentToolCallId: 'agent-1' }, + rawOutput: 'detail', + }); + const permission: BridgeEvent = { + id: 5, + v: 1, + type: 'permission_request', + data: { requestId: 'permission-1', toolCallId: 'child-1' }, + }; + const mainUsage = frame(6, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { usage: { inputTokens: 100, outputTokens: 20 } }, + }); + const original = [nested, usage, childTool, result, permission, mainUsage]; + const before = structuredClone(original); + const projected = summarizeReplay(original); + expect(projected.map((e) => e.id)).toEqual([4, 5, 6]); + expect(projected[0]).toMatchObject({ + id: 4, + promptId: 'prompt-1', + data: { + sessionId: 'session-1', + update: { + toolCallId: 'agent-1', + status: 'completed', + rawInput: { description: 'Review' }, + _meta: { 'qwen.session.recordId': 'record-1' }, + rawOutput: { + result: 'final answer', + subagentSessionReady: true, + executionSummary: { totalToolCalls: 1, totalDurationMs: 200 }, + }, + }, + }, + }); + expect(JSON.stringify(projected)).not.toContain('nested'); + expect(JSON.stringify(projected)).not.toContain('long task prompt'); + expect(projected[1]).toBe(permission); + expect(projected[2]).toBe(mainUsage); + expect(projected[0]).toHaveProperty('data.update.rawOutput.tokenCount', 12); + expect(projected[0]).toHaveProperty( + 'data.update.rawOutput.executionSummary', + { + totalToolCalls: 1, + totalDurationMs: 200, + inputTokens: 10, + outputTokens: 2, + thoughtTokens: 1, + cachedTokens: 5, + totalTokens: 12, + }, + ); + expect(original).toEqual(before); + expect(summarizeReplay(projected)).toEqual(projected); + }); + + it.each(['running', 'completed', 'failed', 'cancelled'])( + 'retains aggregate usage only for settled tasks: %s', + (status) => { + const [event] = summarizeReplay([ + frame(1, { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'completed', + rawOutput: { + type: 'task_execution', + status, + tokenCount: 20, + executionSummary: { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + }, + }, + }), + ]); + if (status === 'running') { + expect(event).not.toHaveProperty('data.update.rawOutput.tokenCount'); + expect(event).toHaveProperty( + 'data.update.rawOutput.executionSummary', + {}, + ); + } else { + expect(event).toHaveProperty('data.update.rawOutput.tokenCount', 20); + expect(event).toHaveProperty('data.update.rawOutput.executionSummary', { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + }); + } + }, + ); + + it('retains typed progress cards and non-agent output, including self-parented tools', () => { + const events = [ + frame(1, { + sessionUpdate: 'tool_call', + kind: 'other', + toolCallId: 'agent-1', + status: 'in_progress', + _meta: { parentToolCallId: 'agent-1', subagentProgress: true }, + }), + frame(2, { + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: { toolName: 'read_file' }, + rawOutput: { toolCalls: ['file data'], taskPrompt: 'file data' }, + }), + ]; + expect(summarizeReplay(events)).toEqual(events); + }); +}); diff --git a/packages/acp-bridge/src/replay-summary.ts b/packages/acp-bridge/src/replay-summary.ts new file mode 100644 index 00000000000..45682ae3d6f --- /dev/null +++ b/packages/acp-bridge/src/replay-summary.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BridgeEvent } from './eventBus.js'; + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function isSummaryReplayEvent(event: BridgeEvent): boolean { + if (event.type !== 'session_update') return true; + const data = record(event.data); + const update = record(data?.['update']) ?? data; + const meta = record(update?.['_meta']); + // Match the SDK normalizer: these liveness frames never render a tool card. + if ( + (update?.['sessionUpdate'] === 'tool_call' || + update?.['sessionUpdate'] === 'tool_call_update') && + update['status'] === 'in_progress' && + typeof update['kind'] !== 'string' && + (meta?.['shellProgress'] !== undefined || + meta?.['subagentProgress'] === true) + ) + return false; + // Self-parented tool frames render as root cards; dropping them would make + // a visible card disappear after a summary reload. + const parent = meta?.['parentToolCallId']; + return ( + typeof parent !== 'string' || + parent.length === 0 || + parent === update?.['toolCallId'] + ); +} + +export function summarizeReplayEvent( + event: BridgeEvent, +): BridgeEvent | undefined { + if (!isSummaryReplayEvent(event)) return undefined; + if (event.type !== 'session_update') return event; + const data = record(event.data); + const update = record(data?.['update']) ?? data; + if (!update) return event; + const meta = record(update['_meta']); + let projected = update; + if ( + update['sessionUpdate'] === 'tool_call' || + update['sessionUpdate'] === 'tool_call_update' + ) { + const output = record(update['rawOutput']); + const agent = + output?.['type'] === 'task_execution' || + meta?.['toolName'] === 'agent' || + meta?.['toolName'] === 'task'; + if (agent) { + if (output) { + // A background launch can complete its tool call while the agent is + // still running. Prefer the task status over the outer tool status. + const status = output['status'] ?? update['status']; + const settled = + status === 'completed' || + status === 'failed' || + status === 'cancelled'; + const { + toolCalls: _toolCalls, + taskPrompt: _taskPrompt, + tokenCount: _tokenCount, + ...summary + } = output; + const executionSummary = record(summary['executionSummary']); + if (!settled && executionSummary) { + const { + inputTokens: _inputTokens, + outputTokens: _outputTokens, + thoughtTokens: _thoughtTokens, + cachedTokens: _cachedTokens, + totalTokens: _totalTokens, + ...stats + } = executionSummary; + summary['executionSummary'] = stats; + } + projected = { + ...projected, + rawOutput: { + ...summary, + ...(settled && _tokenCount !== undefined + ? { tokenCount: _tokenCount } + : {}), + }, + }; + } + const input = record(update['rawInput']); + if (input && 'prompt' in input) { + const { prompt: _prompt, ...summary } = input; + projected = { ...projected, rawInput: summary }; + } + } + } + return projected === update + ? event + : { + ...event, + data: record(data?.['update']) + ? { ...data, update: projected } + : projected, + }; +} + +export function summarizeReplay(events: BridgeEvent[]): BridgeEvent[] { + return events.flatMap((event) => { + const projected = summarizeReplayEvent(event); + return projected ? [projected] : []; + }); +} diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 470ce63512f..98f32b3fd97 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; +import { summarizeReplay } from './replay-summary.js'; import { createTranscriptReplayMachine, createTranscriptToolCallResultUpdate, @@ -1546,6 +1547,61 @@ describe('createTranscriptReplayMachine', () => { expect(machine.snapshot().pendingToolCalls).toHaveLength(2); }); + it('attributes persisted Agent usage to its parent and omits it in summary', () => { + const machine = createTranscriptReplayMachine(); + const result = updates( + machine, + record('agent-result', 'tool_result', { + toolCallResult: { + callId: 'agent-1', + toolName: 'agent', + status: 'success', + resultDisplay: { + type: 'task_execution', + result: 'done', + executionSummary: { + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + }, + }, + }, + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'agent-1', + name: 'agent', + response: { output: 'done' }, + }, + }, + ], + }, + }), + ); + expect(result).toHaveLength(2); + expect(result[1]).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + _meta: { + parentToolCallId: 'agent-1', + usage: { inputTokens: 100, outputTokens: 20 }, + }, + }); + const events = result.map((data, id) => ({ + id: id + 1, + v: 1 as const, + type: 'session_update', + data, + })); + expect(summarizeReplay(events)).toHaveLength(1); + expect(summarizeReplay(events)[0]?.data).toMatchObject({ + sessionUpdate: 'tool_call_update', + rawOutput: { result: 'done', executionSummary: {} }, + }); + expect(machine.snapshot().cumulativeUsage.promptTokens).toBe(100); + }); + it('correlates an id-less result only to one same-name pending call', () => { const machine = createTranscriptReplayMachine(); updates( diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 497b71e0fd2..2ada7287d6f 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -979,7 +979,12 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { const usage = usageFromTaskExecution(resultDisplay); if (Object.keys(usage).length > 0) { this.addUsage(usage); - yield emit(createTranscriptUsageUpdate(usage, meta)); + yield emit( + createTranscriptUsageUpdate(usage, { + ...meta, + extra: { ...meta.extra, parentToolCallId: callId }, + }), + ); } } } diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 275ef89df37..37417b965f0 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -5882,7 +5882,9 @@ export class AcpDispatcher { sessionId, // SECURITY NOTE: `params.sessionId` already equals the routing // `sessionId` (both from the same params), so there's no routing - // divergence today. If the bridge ever trusts an additional + // divergence today. eventDetailMode is an intentional daemon extension: + // like REST prompt, it controls this turn's shared retention/delivery. + // If the bridge ever trusts an additional privileged // `sendPrompt` field by name (e.g. a priority/temperature override), // force-stamp it here like the REST surface does (`{ ...body, // sessionId, prompt }`) so it can't become client-controlled. diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 3df523d8cb4..42d3425598d 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -1862,6 +1862,34 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it.each(['full', 'summary'] as const)( + 'accepts the daemon eventDetailMode extension over ACP: %s', + async (eventDetailMode) => { + const send = vi.spyOn(bridge, 'sendPrompt'); + const connId = await initialize(); + await newSession(connId); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 5, + method: 'session/prompt', + params: { + sessionId: 'sess-1', + prompt: [{ type: 'text', text: 'hello' }], + eventDetailMode, + }, + }); + expect(ack.status).toBe(202); + await vi.waitFor(() => + expect(send).toHaveBeenCalledWith( + 'sess-1', + expect.objectContaining({ eventDetailMode }), + expect.any(AbortSignal), + expect.anything(), + ), + ); + }, + ); + it.each([ { meta: undefined, context: {} }, { meta: { 'qwen.daemon.submittedPrompt': 'forged' }, context: {} }, diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index e2ebd0f6a53..d4e14a54e16 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -227,6 +227,7 @@ export type RestoreStandaloneSessionOptions = Pick< | 'clientId' | 'historyPageSize' | 'liveReplayMode' + | 'compactedReplayMode' | 'hideInheritedHistory' | 'approvalMode' >; @@ -2336,6 +2337,9 @@ export class StandaloneSessionService { ...(action === 'load' && options.historyPageSize !== undefined ? { historyPageSize: options.historyPageSize } : {}), + ...(action === 'load' && options.compactedReplayMode !== undefined + ? { compactedReplayMode: options.compactedReplayMode } + : {}), ...(action === 'load' && options.liveReplayMode !== undefined ? { liveReplayMode: options.liveReplayMode } : {}), diff --git a/packages/cli/src/serve/rest-integration-docs-contract.test.ts b/packages/cli/src/serve/rest-integration-docs-contract.test.ts index a59ed756667..af62443eecf 100644 --- a/packages/cli/src/serve/rest-integration-docs-contract.test.ts +++ b/packages/cli/src/serve/rest-integration-docs-contract.test.ts @@ -580,6 +580,7 @@ describe('REST integration documentation contract', () => { const loadPost = openApi.paths?.['/session/{id}/load']?.post; expect(requestFields(loadPost)).toEqual([ 'approvalMode', + 'compactedReplayMode', 'cwd', 'historyPageSize', 'liveReplayMode', @@ -594,6 +595,36 @@ describe('REST integration documentation contract', () => { | undefined )?.content?.['application/json']?.schema?.$ref as string, ) as { properties?: Record }; + const schemas = openApi.components?.schemas ?? {}; + for (const [schemaName, field] of [ + ['RestoreSessionRequest', 'compactedReplayMode'], + ['PromptRequest', 'eventDetailMode'], + ]) { + expect(schemas[schemaName!]).toMatchObject({ + properties: { + [field!]: { + type: 'string', + enum: ['full', 'summary'], + default: 'full', + }, + }, + }); + } + expect( + openApi.paths?.['/session/{id}/transcript']?.get?.parameters, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + in: 'query', + name: 'compactedReplayMode', + schema: { + type: 'string', + enum: ['full', 'summary'], + default: 'full', + }, + }), + ]), + ); expect(loadSchema.properties?.['historyPageSize']?.maximum).toBe( SESSION_TRANSCRIPT_MAX_LIMIT, ); diff --git a/packages/cli/src/serve/routes/session-prompt-terminals.test.ts b/packages/cli/src/serve/routes/session-prompt-terminals.test.ts index f2e30225253..b57bd8fb7d0 100644 --- a/packages/cli/src/serve/routes/session-prompt-terminals.test.ts +++ b/packages/cli/src/serve/routes/session-prompt-terminals.test.ts @@ -164,6 +164,291 @@ describe('POST /session/:id/load prompt terminals', () => { archiveMocks.assertSessionLoadable.mockResolvedValue('active'); }); + it.each(['load', 'resume'] as const)( + 'validates summary modes on %s before restore', + async (action) => { + const fixture = makeFixture(); + const app = makeApp(fixture); + for (const field of ['compactedReplayMode', 'liveReplayMode'] as const) { + const response = await request(app) + .post(`/session/${fixture.sessionId}/${action}`) + .send({ [field]: 'invalid' }); + expect(response.status).toBe(400); + expect(response.body.code).toBe( + field === 'compactedReplayMode' + ? 'invalid_compacted_replay_mode' + : 'invalid_live_replay_mode', + ); + } + expect(fixture.runtime.bridge.loadSession).not.toHaveBeenCalled(); + expect(fixture.runtime.bridge.resumeSession).not.toHaveBeenCalled(); + }, + ); + + it.each(['load', 'resume'] as const)( + 'forwards load-only summary fields only on load: %s', + async (action) => { + const fixture = makeFixture(); + writeTranscript(fixture, [chatRecord(fixture, 'u1', null, 'question')]); + const response = await request(makeApp(fixture)) + .post(`/session/${fixture.sessionId}/${action}`) + .send({ + historyPageSize: 200, + liveReplayMode: 'summary', + compactedReplayMode: 'summary', + }); + expect(response.status).toBe(200); + const restore = vi.mocked( + action === 'load' + ? fixture.runtime.bridge.loadSession + : fixture.runtime.bridge.resumeSession, + ); + const params = restore.mock.calls[0]?.[0]; + if (action === 'load') + expect(params).toMatchObject({ + historyPageSize: 200, + liveReplayMode: 'summary', + compactedReplayMode: 'summary', + }); + else { + expect(params).not.toHaveProperty('historyPageSize'); + expect(params).not.toHaveProperty('liveReplayMode'); + expect(params).not.toHaveProperty('compactedReplayMode'); + } + }, + ); + + it.each(['prompt', 'mid-turn-message'] as const)( + 'rejects invalid eventDetailMode on %s', + async (route) => { + const fixture = makeFixture(); + vi.mocked(fixture.runtime.bridge.getSessionSummary).mockReturnValue({ + sessionId: fixture.sessionId, + } as ReturnType); + const response = await request(makeApp(fixture)) + .post(`/session/${fixture.sessionId}/${route}`) + .send({ + prompt: [{ type: 'text', text: 'hello' }], + message: 'hello', + eventDetailMode: 'invalid', + }); + expect(response.status).toBe(400); + expect(response.body.code).toBe('invalid_event_detail_mode'); + }, + ); + + it.each(['full', 'summary'] as const)( + 'forwards prompt eventDetailMode to the owner: %s', + async (eventDetailMode) => { + const fixture = makeFixture(); + const bridge = fixture.runtime.bridge; + vi.mocked(bridge.getSessionSummary).mockReturnValue({ + sessionId: fixture.sessionId, + } as ReturnType); + bridge.getSessionLastEventId = vi.fn(() => 0); + bridge.getSessionEventEpoch = vi.fn(() => 'epoch'); + bridge.sendPrompt = vi.fn(async () => ({ + stopReason: 'end_turn' as const, + })); + const prompt = [{ type: 'text', text: 'hello' }]; + const response = await request(makeApp(fixture)) + .post(`/session/${fixture.sessionId}/prompt`) + .send({ prompt, eventDetailMode }); + expect(response.status).toBe(202); + expect(bridge.sendPrompt).toHaveBeenCalledWith( + fixture.sessionId, + { sessionId: fixture.sessionId, prompt, eventDetailMode }, + expect.any(AbortSignal), + expect.objectContaining({ promptId: response.body.promptId }), + ); + }, + ); + + it('forwards mid-turn summary to the owner without changing idle rejection', async () => { + const fixture = makeFixture(); + vi.mocked(fixture.runtime.bridge.getSessionSummary).mockReturnValue({ + sessionId: fixture.sessionId, + } as ReturnType); + fixture.runtime.bridge.enqueueMidTurnMessage = vi.fn(() => ({ + accepted: false, + reason: 'session_idle' as const, + })); + const response = await request(makeApp(fixture)) + .post(`/session/${fixture.sessionId}/mid-turn-message`) + .send({ message: 'hello', messageId: 'mid', eventDetailMode: 'summary' }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ accepted: false, reason: 'session_idle' }); + expect(fixture.runtime.bridge.enqueueMidTurnMessage).toHaveBeenCalledWith( + fixture.sessionId, + 'hello', + undefined, + 'mid', + { rejectIfIdle: true, eventDetailMode: 'summary' }, + ); + }); + + it.each([false, true])( + 'rejects invalid transcript summary mode (qualified=%s)', + async (qualified) => { + const fixture = makeFixture(); + const prefix = qualified + ? `/workspaces/${encodeURIComponent(fixture.workspaceDir)}` + : ''; + const response = await request(makeApp(fixture)) + .get(`${prefix}/session/${fixture.sessionId}/transcript`) + .query({ compactedReplayMode: 'invalid' }); + expect(response.status).toBe(400); + expect(response.body.code).toBe('invalid_compacted_replay_mode'); + }, + ); + + it.each([false, true])( + 'projects transcript summary without changing pagination (qualified=%s)', + async (qualified) => { + const fixture = makeFixture(); + const usage = { + rounds: 1, + totalDurationMs: 200, + totalToolCalls: 1, + successfulToolCalls: 1, + failedToolCalls: 0, + successRate: 100, + thoughtTokens: 0, + toolUsage: [], + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + totalTokens: 120, + }; + const output = { + type: 'task_execution' as const, + status: 'completed' as const, + subagentName: 'reviewer', + taskDescription: 'review', + tokenCount: 20, + result: 'agent answer', + taskPrompt: 'nested prompt', + toolCalls: [ + { + callId: 'child-1', + name: 'nested tool', + status: 'success' as const, + }, + ], + executionSummary: usage, + }; + writeTranscript(fixture, [ + chatRecord(fixture, 'u1', null, 'question'), + { + ...chatRecord(fixture, 'a1', 'u1', ''), + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'agent-1', + name: 'agent', + args: { prompt: 'nested prompt' }, + }, + }, + ], + }, + }, + { + ...chatRecord(fixture, 'r1', 'a1', ''), + type: 'tool_result', + toolCallResult: { + callId: 'agent-1', + status: 'success', + resultDisplay: output, + }, + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'agent-1', + name: 'agent', + response: { output: 'agent answer' }, + }, + }, + ], + }, + }, + chatRecord(fixture, 'a2', 'r1', 'main answer'), + ]); + fixture.runtime.bridge.getSessionTranscriptPage = vi.fn(async () => ({ + v: 1 as const, + sessionId: fixture.sessionId, + hasMore: true, + nextCursor: 'next-page', + targetRecordId: 'r1', + hasOlder: true, + events: [ + { + v: 1 as const, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'completed', + rawOutput: output, + }, + }, + }, + { + v: 1 as const, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { parentToolCallId: 'agent-1', usage }, + }, + }, + }, + ], + })); + const prefix = qualified + ? `/workspaces/${encodeURIComponent(fixture.workspaceDir)}` + : ''; + const app = makeApp(fixture); + const fetchPage = (mode: string) => + request(app) + .get(`${prefix}/session/${fixture.sessionId}/transcript`) + .query({ compactedReplayMode: mode, limit: 200 }); + const full = await fetchPage('full'); + const summary = await fetchPage('summary'); + expect(full.status).toBe(200); + expect(summary.status).toBe(200); + const { events: fullEvents, ...fullPage } = full.body; + const { events: summaryEvents, ...summaryPage } = summary.body; + expect(summaryPage).toEqual(fullPage); + expect(JSON.stringify(fullEvents)).toContain('parentToolCallId'); + expect(JSON.stringify(fullEvents)).toContain('nested tool'); + expect(JSON.stringify(summaryEvents)).not.toContain('parentToolCallId'); + expect(JSON.stringify(summaryEvents)).not.toContain('nested tool'); + expect(JSON.stringify(summaryEvents)).not.toContain('nested prompt'); + expect(JSON.stringify(summaryEvents)).toContain('agent answer'); + const completed = expect.objectContaining({ + rawOutput: expect.objectContaining({ + tokenCount: 20, + executionSummary: usage, + }), + }); + expect(summaryEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: qualified + ? completed + : expect.objectContaining({ update: completed }), + }), + ]), + ); + }, + ); + it('reconciles a dangling prompt and returns promptTerminals', async () => { const fixture = makeFixture(); writeTranscript(fixture, [ diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3bc2b53ee90..76fdbd37545 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -60,6 +60,7 @@ import { extractErrorCode, extractErrorMessage, parseSessionSource, + summarizeReplay, } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, @@ -660,16 +661,22 @@ function parseHistoryPageSize( return value as number; } -function parseLiveReplayMode( +function parseReplayMode( body: Record, res: Response, + key: 'liveReplayMode' | 'compactedReplayMode' | 'eventDetailMode', ): 'full' | 'summary' | undefined | null { - const value = body['liveReplayMode']; + const value = body[key]; if (value === undefined) return undefined; if (value !== 'full' && value !== 'summary') { res.status(400).json({ - error: '`liveReplayMode` must be `full` or `summary`', - code: 'invalid_live_replay_mode', + error: `\`${key}\` must be \`full\` or \`summary\``, + code: + key === 'liveReplayMode' + ? 'invalid_live_replay_mode' + : key === 'compactedReplayMode' + ? 'invalid_compacted_replay_mode' + : 'invalid_event_detail_mode', }); return null; } @@ -3679,8 +3686,14 @@ export function registerSessionRoutes( const historyPageSize = action === 'load' ? parseHistoryPageSize(body ?? {}, res) : undefined; if (historyPageSize === null) return; - const liveReplayMode = parseLiveReplayMode(body ?? {}, res); + const liveReplayMode = parseReplayMode(body ?? {}, res, 'liveReplayMode'); if (liveReplayMode === null) return; + const compactedReplayMode = parseReplayMode( + body ?? {}, + res, + 'compactedReplayMode', + ); + if (compactedReplayMode === null) return; const restoreSource = parseRequestedSessionSource(body, res); if (restoreSource === null) return; const clientId = parseClientIdHeader(req, res); @@ -3727,6 +3740,9 @@ export function registerSessionRoutes( ...(clientId !== undefined ? { clientId } : {}), ...(historyPageSize !== undefined ? { historyPageSize } : {}), ...(liveReplayMode !== undefined ? { liveReplayMode } : {}), + ...(compactedReplayMode !== undefined + ? { compactedReplayMode } + : {}), ...(approvalMode !== undefined ? { approvalMode } : {}), }, ); @@ -3998,6 +4014,9 @@ export function registerSessionRoutes( ? { historyPageSize } : {}), ...(liveReplayMode !== undefined ? { liveReplayMode } : {}), + ...(compactedReplayMode !== undefined + ? { compactedReplayMode } + : {}), ...(clientId !== undefined ? { clientId } : {}), ...(approvalMode !== undefined ? { approvalMode } : {}), ...restoreRequestMetadata, @@ -5555,6 +5574,12 @@ export function registerSessionRoutes( const route = 'GET /session/:id/transcript'; const sessionId = requireSessionId(req, res); if (sessionId === null) return; + const compactedReplayMode = parseReplayMode( + req.query, + res, + 'compactedReplayMode', + ); + if (compactedReplayMode === null) return; const limit = parseTranscriptLimitQuery(req.query['limit'], res); if (limit === null) return; const cursor = parseTranscriptCursorQuery(req.query['cursor'], res); @@ -5643,9 +5668,10 @@ export function registerSessionRoutes( const serialized = serializeWorkspaceTranscriptResponse( { ...result, - events: (result.events ?? []).map((event) => - redactSdkSurfaceEvent(event, workspaceTrusted), - ), + events: (compactedReplayMode === 'summary' + ? summarizeReplay(result.events ?? []) + : (result.events ?? []) + ).map((event) => redactSdkSurfaceEvent(event, workspaceTrusted)), }, sessionId, ); @@ -5672,6 +5698,12 @@ export function registerSessionRoutes( if (!qualifiedTarget) return; const preResolvedRuntime = qualifiedTarget.kind === 'ordinary' ? qualifiedTarget.runtime : undefined; + const compactedReplayMode = parseReplayMode( + req.query, + res, + 'compactedReplayMode', + ); + if (compactedReplayMode === null) return; const limit = parseTranscriptLimitQuery(req.query['limit'], res); if (limit === null) return; const cursor = parseTranscriptCursorQuery(req.query['cursor'], res); @@ -5877,7 +5909,9 @@ export function registerSessionRoutes( ); if (result === undefined) return; const serialized = serializeWorkspaceTranscriptResponse( - result, + compactedReplayMode === 'summary' + ? { ...result, events: summarizeReplay(result.events) } + : result, sessionId, ); res @@ -6851,6 +6885,8 @@ export function registerSessionRoutes( async (req, res, sessionId, runtime, onPromptAdmitted) => { const ownerBridge = runtime.bridge; const body = safeBody(req); + const eventDetailMode = parseReplayMode(body, res, 'eventDetailMode'); + if (eventDetailMode === null) return; const prompt = body['prompt']; if (!Array.isArray(prompt) || prompt.length === 0) { res.status(400).json({ @@ -8879,6 +8915,8 @@ export function registerSessionRoutes( 'POST /session/:id/mid-turn-message', (req, res, sessionId, runtime) => { const body = safeBody(req); + const eventDetailMode = parseReplayMode(body, res, 'eventDetailMode'); + if (eventDetailMode === null) return; const message = body['message']; const messageId = body['messageId']; // Validate (and length-check, and enqueue) the TRIMMED value — the bridge @@ -8955,6 +8993,7 @@ export function registerSessionRoutes( typeof messageId === 'string' ? messageId : undefined, { rejectIfIdle: true, + ...(eventDetailMode !== undefined ? { eventDetailMode } : {}), ...(mediaBlocks ? { content: mediaBlocks } : {}), }, ); diff --git a/packages/cli/src/serve/routes/standalone-sessions.test.ts b/packages/cli/src/serve/routes/standalone-sessions.test.ts index 092c1a4096a..8dd9be006b4 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.test.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.test.ts @@ -109,6 +109,16 @@ function createHarness({ describe('standalone session routes', () => { beforeEach(() => vi.restoreAllMocks()); + it('rejects invalid standalone compacted replay mode', async () => { + const { app, service } = createHarness(); + const response = await request(app) + .post(`/standalone/sessions/${sessionId}/load`) + .send({ compactedReplayMode: 'invalid' }); + expect(response.status).toBe(400); + expect(response.body.code).toBe('invalid_request'); + expect(service.load).not.toHaveBeenCalled(); + }); + it('returns session options without accepting workspace inputs', async () => { const { app, service } = createHarness(); @@ -336,6 +346,7 @@ describe('standalone session routes', () => { .send({ historyPageSize: 20, liveReplayMode: 'summary', + compactedReplayMode: 'summary', hideInheritedHistory: true, }); @@ -344,6 +355,7 @@ describe('standalone session routes', () => { clientId: 'client-1', historyPageSize: 20, liveReplayMode: 'summary', + compactedReplayMode: 'summary', hideInheritedHistory: true, }); diff --git a/packages/cli/src/serve/routes/standalone-sessions.ts b/packages/cli/src/serve/routes/standalone-sessions.ts index d48377101d2..0b19650482c 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.ts @@ -110,6 +110,7 @@ function parseRestoreOptions( const body = requireExactBody(req, res, [ 'historyPageSize', 'liveReplayMode', + 'compactedReplayMode', 'hideInheritedHistory', 'approvalMode', ]); @@ -138,6 +139,18 @@ function parseRestoreOptions( sendInvalidRequest(res, '`liveReplayMode` must be `full` or `summary`.'); return undefined; } + const compactedReplayMode = body['compactedReplayMode']; + if ( + compactedReplayMode !== undefined && + compactedReplayMode !== 'full' && + compactedReplayMode !== 'summary' + ) { + sendInvalidRequest( + res, + '`compactedReplayMode` must be `full` or `summary`.', + ); + return undefined; + } const hideInheritedHistory = body['hideInheritedHistory']; if ( hideInheritedHistory !== undefined && @@ -157,6 +170,7 @@ function parseRestoreOptions( ? { historyPageSize: historyPageSize as number } : {}), ...(liveReplayMode !== undefined ? { liveReplayMode } : {}), + ...(compactedReplayMode !== undefined ? { compactedReplayMode } : {}), ...(hideInheritedHistory !== undefined ? { hideInheritedHistory } : {}), ...(approvalMode !== undefined ? { approvalMode } : {}), }; diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1c50c342ed5..24c5f63b789 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -443,6 +443,8 @@ function transcriptPageSuffix( opts: DaemonSessionTranscriptPageOptions, ): string { const query = new URLSearchParams(); + if (opts.compactedReplayMode !== undefined) + query.set('compactedReplayMode', opts.compactedReplayMode); if (opts.cursor !== undefined) query.set('cursor', opts.cursor); if (opts.direction !== undefined) query.set('direction', opts.direction); if (opts.atRecordId !== undefined) query.set('atRecordId', opts.atRecordId); @@ -688,6 +690,8 @@ export interface RestoreSessionRequest { historyPageSize?: number; /** Load-only live-turn replay projection. Omit for the complete journal. */ liveReplayMode?: 'full' | 'summary'; + /** Load-only response projection for durable replay; defaults to full. */ + compactedReplayMode?: 'full' | 'summary'; /** Restore-time attribution for legacy/unattributed sessions. */ sourceType?: string; /** Optional source-specific identifier. Requires `sourceType`. */ @@ -715,6 +719,8 @@ export interface WorktreeResetSessionRequest { } export interface PromptRequest { + /** Per-prompt projection for all subscribers and ring replay; defaults to full. */ + eventDetailMode?: 'full' | 'summary'; prompt: PromptContentBlock[]; /** Deliver the successful final answer directly through a channel worker. */ delivery?: DaemonChannelDelivery; @@ -3924,6 +3930,9 @@ export class DaemonClient { ...(action === 'load' && req.historyPageSize !== undefined ? { historyPageSize: req.historyPageSize } : {}), + ...(action === 'load' && req.compactedReplayMode !== undefined + ? { compactedReplayMode: req.compactedReplayMode } + : {}), ...(action === 'load' && req.liveReplayMode !== undefined ? { liveReplayMode: req.liveReplayMode } : {}), @@ -4287,6 +4296,7 @@ export class DaemonClient { message: string, opts?: { signal?: AbortSignal; + eventDetailMode?: 'full' | 'summary'; clientId?: string; messageId?: string; content?: PromptContentBlock[]; @@ -4307,6 +4317,9 @@ export class DaemonClient { body: JSON.stringify({ message, messageId: opts?.messageId, + ...(opts?.eventDetailMode !== undefined + ? { eventDetailMode: opts.eventDetailMode } + : {}), ...(opts?.content && opts.content.length > 0 ? { content: opts.content } : {}), diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 8eaacfabc97..264dcd8851e 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -1005,12 +1005,16 @@ export class DaemonSessionClient { message: string, opts?: { signal?: AbortSignal; + eventDetailMode?: 'full' | 'summary'; messageId?: string; content?: PromptContentBlock[]; }, ): Promise { return this.client.enqueueMidTurnMessage(this.sessionId, message, { ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.eventDetailMode !== undefined + ? { eventDetailMode: opts.eventDetailMode } + : {}), ...(opts?.messageId ? { messageId: opts.messageId } : {}), ...(opts?.content && opts.content.length > 0 ? { content: opts.content } diff --git a/packages/sdk-typescript/src/daemon/standalone-sessions.ts b/packages/sdk-typescript/src/daemon/standalone-sessions.ts index 6ec29c87de9..b373e819259 100644 --- a/packages/sdk-typescript/src/daemon/standalone-sessions.ts +++ b/packages/sdk-typescript/src/daemon/standalone-sessions.ts @@ -34,6 +34,7 @@ export interface RestoreStandaloneSessionRequest { approvalMode?: DaemonApprovalMode; historyPageSize?: number; liveReplayMode?: 'full' | 'summary'; + compactedReplayMode?: 'full' | 'summary'; hideInheritedHistory?: boolean; timeoutMs?: number; } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index f7a367cb950..74bd75f41cc 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1522,6 +1522,8 @@ export interface DaemonSessionExportResult { } export interface DaemonSessionTranscriptPageOptions { + /** Projection of persisted replay; defaults to full. */ + compactedReplayMode?: 'full' | 'summary'; cursor?: string; /** Start a forward page containing this persisted navigation turn UUID. */ atRecordId?: string; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 889e4b4311f..a43aa3927d0 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -412,6 +412,7 @@ function applyDaemonTranscriptEvent( } break; case 'assistant.usage': + if (isSubagentUsageDuplicate(next, event)) break; if (event.parentToolCallId && !next.retainSubagentBlocks) { applySubagentUsageToParentTool(next, event); } else { @@ -651,7 +652,6 @@ function applyAssistantUsage( state: DaemonTranscriptState, event: Extract, ): void { - if (isLegacySubagentUsageDuplicate(state, event)) return; const activeBlockId = state.activeAssistantBlockId ?? (event.parentToolCallId @@ -668,11 +668,11 @@ function applyAssistantUsage( block.updatedAt = state.now; } -function isLegacySubagentUsageDuplicate( +function isSubagentUsageDuplicate( state: DaemonTranscriptState, event: Extract, ): boolean { - if (event.parentToolCallId || !event.sourceRecordIds?.length) return false; + if (!event.sourceRecordIds?.length) return false; const sourceRecordIds = new Set(event.sourceRecordIds); for (let i = state.blocks.length - 1; i >= 0; i -= 1) { const block = state.blocks[i]!; @@ -688,6 +688,9 @@ function isLegacySubagentUsageDuplicate( rawOutput && isRecord(rawOutput['executionSummary']) ? rawOutput['executionSummary'] : undefined; + // The same record and parent identify an aggregate already on the card, + // even when merging live usage retained larger counts than this snapshot. + if (summary && event.parentToolCallId === block.toolCallId) return true; return ( summary?.['inputTokens'] === event.usage.inputTokens && summary['outputTokens'] === event.usage.outputTokens && diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 67c57eab732..e9050b27819 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -2738,6 +2738,7 @@ describe('DaemonClient', () => { await expect( client.getSessionTranscriptPage('with/slash', { + compactedReplayMode: 'summary', cursor: 'cur 1', limit: 2, clientId: 'client-1', @@ -2745,7 +2746,7 @@ describe('DaemonClient', () => { ).resolves.toEqual(body); expect(calls[0]).toMatchObject({ - url: 'http://daemon/session/with%2Fslash/transcript?cursor=cur+1&limit=2', + url: 'http://daemon/session/with%2Fslash/transcript?compactedReplayMode=summary&cursor=cur+1&limit=2', method: 'GET', headers: { authorization: 'Bearer secret', @@ -4051,6 +4052,7 @@ describe('DaemonClient', () => { const session = await client.loadSession('s-1', { workspaceCwd: '/work/a', liveReplayMode: 'summary', + compactedReplayMode: 'summary', timeoutMs: 0, }); @@ -4060,6 +4062,7 @@ describe('DaemonClient', () => { expect(JSON.parse(calls[0]!.body!)).toEqual({ cwd: '/work/a', liveReplayMode: 'summary', + compactedReplayMode: 'summary', }); expect(calls[0]?.signal).toBeNull(); }); @@ -4112,6 +4115,7 @@ describe('DaemonClient', () => { workspaceCwd: '/w', historyPageSize: 100, liveReplayMode: 'summary', + compactedReplayMode: 'summary', }); expect(calls[0]?.url).toBe('http://daemon/session/s-1/resume'); @@ -6273,12 +6277,13 @@ describe('DaemonClient', () => { const result = await client.enqueueMidTurnMessage( 's-1', 'also check tests', - { messageId: 'client-mid-1' }, + { messageId: 'client-mid-1', eventDetailMode: 'summary' }, ); expect(result).toEqual({ accepted: true, messageId: 'mid-1' }); expect(calls[0]?.url).toBe('http://daemon/session/s-1/mid-turn-message'); expect(calls[0]?.method).toBe('POST'); expect(JSON.parse(calls[0]?.body as string)).toEqual({ + eventDetailMode: 'summary', message: 'also check tests', messageId: 'client-mid-1', }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 6b536d96e4a..2580a021286 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -2018,6 +2018,7 @@ describe('DaemonSessionClient', () => { await expect( session.enqueueMidTurnMessage('shared message', { messageId: 'stable-1', + eventDetailMode: 'summary', }), ).resolves.toEqual({ accepted: true, messageId: 'stable-1' }); expect(calls[0]?.url).toBe( @@ -2028,6 +2029,7 @@ describe('DaemonSessionClient', () => { expect(JSON.parse(calls[0]!.body!)).toEqual({ message: 'shared message', messageId: 'stable-1', + eventDetailMode: 'summary', }); }); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 6d0f3b56158..449d6e92a86 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -840,40 +840,54 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.blocks[3]).not.toHaveProperty('usage'); }); - it('deduplicates legacy sub-agent usage repeated after its execution summary', () => { - const state = reduceDaemonTranscriptEvents( - createDaemonTranscriptState({ now: 1 }), - [ - { type: 'user.text.delta', text: 'question' }, - { type: 'assistant.text.delta', text: 'delegating' }, - { - type: 'tool.update', - toolCallId: 'sub-1', - status: 'completed', - sourceRecordIds: ['subagent-result'], - rawOutput: { - executionSummary: { + it.each([ + [undefined, true], + [undefined, false], + ['sub-1', true], + ['sub-1', false], + ] as const)( + 'deduplicates persisted sub-agent usage (parent=%s, retain=%s)', + (parentToolCallId, retainSubagentBlocks) => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1, retainSubagentBlocks }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'assistant.text.delta', text: 'delegating' }, + { + type: 'tool.update', + toolCallId: 'sub-1', + status: 'completed', + sourceRecordIds: ['subagent-result'], + rawOutput: { + executionSummary: { + inputTokens: 5000, + outputTokens: 800, + cachedTokens: 4500, + }, + }, + }, + { + type: 'assistant.usage', + ...(parentToolCallId ? { parentToolCallId } : {}), + usage: { inputTokens: 5000, outputTokens: 800, cachedTokens: 4500, }, + sourceRecordIds: ['subagent-result'], }, - }, - { - type: 'assistant.usage', - usage: { - inputTokens: 5000, - outputTokens: 800, - cachedTokens: 4500, - }, - sourceRecordIds: ['subagent-result'], - }, - ], - { now: 2 }, - ); + ], + { now: 2 }, + ); - expect(state.blocks[1]).not.toHaveProperty('usage'); - }); + expect(state.blocks[1]).not.toHaveProperty('usage'); + expect(state.blocks[2]).toHaveProperty('rawOutput.executionSummary', { + inputTokens: 5000, + outputTokens: 800, + cachedTokens: 4500, + }); + }, + ); it('keeps sub-agent usage in the parent turn total by default', () => { const state = reduceDaemonTranscriptEvents( @@ -9387,73 +9401,85 @@ describe('parallel subAgent text interleaving fix', () => { expect(state.blocks[1]).not.toHaveProperty('content'); }); - it('preserves accumulated subagent usage when completed rawOutput has lower totals', () => { - let state = createDaemonTranscriptState({ - now: 1, - retainSubagentBlocks: false, - }); + it.each([ + { inputTokens: 1000, outputTokens: 200, cachedTokens: 100 }, + { outputTokens: 200 }, + ])( + 'preserves accumulated usage after a lower or partial persisted aggregate: %j', + (summary) => { + let state = createDaemonTranscriptState({ + now: 1, + retainSubagentBlocks: false, + }); - state = reduceDaemonTranscriptEvents(state, [ - { - type: 'tool.update', - toolCallId: 'agent-task-B', - toolName: 'agent', - status: 'running', - rawOutput: { type: 'task_execution', status: 'running' }, - }, - { - type: 'assistant.usage', - usage: { inputTokens: 5000, outputTokens: 800, cachedTokens: 200 }, - parentToolCallId: 'agent-task-B', - }, - ] as DaemonUiEvent[]); - - expect(state.blocks[0]).toMatchObject({ - kind: 'tool', - rawOutput: { - executionSummary: { - inputTokens: 5000, - outputTokens: 800, - cachedTokens: 200, - totalTokens: 5800, + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'tool.update', + toolCallId: 'agent-task-B', + toolName: 'agent', + status: 'running', + rawOutput: { type: 'task_execution', status: 'running' }, }, - }, - }); + { + type: 'assistant.usage', + usage: { inputTokens: 5000, outputTokens: 800, cachedTokens: 200 }, + parentToolCallId: 'agent-task-B', + }, + ] as DaemonUiEvent[]); - state = reduceDaemonTranscriptEvents(state, [ - { - type: 'tool.update', - toolCallId: 'agent-task-B', - toolName: 'agent', + expect(state.blocks[0]).toMatchObject({ + kind: 'tool', + rawOutput: { + executionSummary: { + inputTokens: 5000, + outputTokens: 800, + cachedTokens: 200, + totalTokens: 5800, + }, + }, + }); + + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'tool.update', + toolCallId: 'agent-task-B', + toolName: 'agent', + status: 'completed', + sourceRecordIds: ['result-B'], + rawOutput: { + type: 'task_execution', + status: 'completed', + executionSummary: summary, + }, + }, + { + type: 'assistant.usage', + parentToolCallId: 'agent-task-B', + sourceRecordIds: ['result-B'], + usage: { + inputTokens: summary.inputTokens ?? 0, + outputTokens: summary.outputTokens, + cachedTokens: summary.cachedTokens ?? 0, + }, + }, + ] as DaemonUiEvent[]); + + expect(state.blocks[0]).toMatchObject({ + kind: 'tool', status: 'completed', rawOutput: { type: 'task_execution', status: 'completed', executionSummary: { - inputTokens: 0, - outputTokens: 0, - cachedTokens: 0, - totalTokens: 0, + inputTokens: 5000, + outputTokens: 800, + cachedTokens: 200, + totalTokens: 5800, }, }, - }, - ] as DaemonUiEvent[]); - - expect(state.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'completed', - rawOutput: { - type: 'task_execution', - status: 'completed', - executionSummary: { - inputTokens: 5000, - outputTokens: 800, - cachedTokens: 200, - totalTokens: 5800, - }, - }, - }); - }); + }); + }, + ); it('keeps merged subagent totals consistent without mutating the event', () => { let state = createDaemonTranscriptState({ diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 1aa306ac051..762ed245c13 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -4458,6 +4458,26 @@ describe('MessageList — turn collapse (DOM)', () => { expect(text.indexOf('↓5.1k')).toBeLessThan(text.indexOf('1 tool call')); }); + it('includes final subagent usage in the processing row', () => { + const agent = agentMsg('summary-agent'); + agent.tools[0]!.rawOutput = { + type: 'task_execution', + status: 'completed', + executionSummary: { inputTokens: 1000, outputTokens: 200 }, + }; + const c = mount( + [ + userMsg('u1'), + agent, + { ...asstMsg('a1'), usage: { inputTokens: 2000, outputTokens: 300 } }, + ], + undefined, + { isResponding: true }, + ); + expect(c.textContent).toContain('Processing'); + expect(c.textContent).toContain('↑3.0k ↓500'); + }); + it('does not add tool summary usage when full transcript usage includes it', () => { const agent = agentMsg('nested'); agent.tools[0]!.rawOutput = { diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts b/packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts index 01dc9a882fd..220b29de303 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts @@ -28,6 +28,7 @@ describe('on-demand subagent transcript projection', () => { terminateReason: 'max_turns', skills: ['repo-ops'], result: 'large result', + tokenCount: 120, toolCalls: [{ callId: 'read-1' }], executionSummary: { totalToolCalls: 1, @@ -55,6 +56,10 @@ describe('on-demand subagent transcript projection', () => { usage: { inputTokens: 10, outputTokens: 2, cachedTokens: 8 }, parentToolCallId: 'agent-1', }, + { + type: 'assistant.usage', + usage: { inputTokens: 200, outputTokens: 30 }, + }, ]; const result = projectMainTranscriptEventsForTesting(events); @@ -72,17 +77,20 @@ describe('on-demand subagent transcript projection', () => { skills: ['repo-ops'], executionSummary: { totalToolCalls: 1, - inputTokens: 100, - outputTokens: 20, - cachedTokens: 40, - totalTokens: 120, }, }, }); expect(result[1]).toMatchObject({ type: 'assistant.usage', - usage: { inputTokens: 10, outputTokens: 2, cachedTokens: 8 }, - parentToolCallId: 'agent-1', + usage: { inputTokens: 200, outputTokens: 30 }, + }); + expect(result[0]).toHaveProperty('rawOutput.tokenCount', 120); + expect(result[0]).toHaveProperty('rawOutput.executionSummary', { + totalToolCalls: 1, + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + totalTokens: 120, }); expect(result[0]).not.toHaveProperty('rawOutput.result'); expect(result[0]).not.toHaveProperty('rawOutput.toolCalls'); @@ -91,6 +99,59 @@ describe('on-demand subagent transcript projection', () => { ).toMatchObject({ prompt: `${'p'.repeat(240)}…` }); }); + it.each(['running', 'completed', 'failed', 'cancelled'] as const)( + 'preserves settled usage through projection and repeated replay: %s', + (status) => { + const events = projectMainTranscriptEventsForTesting([ + { + type: 'tool.update', + toolCallId: 'agent-usage', + toolName: 'agent', + status: 'completed', + rawOutput: { + type: 'task_execution', + status, + tokenCount: 20, + executionSummary: { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + }, + }, + }, + { + type: 'assistant.usage', + parentToolCallId: 'agent-usage', + usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 40 }, + }, + ]); + expect(events).toHaveLength(1); + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ retainSubagentBlocks: false }), + events, + ); + state = reduceDaemonTranscriptEvents(state, events); + expect(state.blocks).toHaveLength(1); + if (status === 'running') { + expect(state.blocks[0]).not.toHaveProperty('rawOutput.tokenCount'); + expect(state.blocks[0]).toHaveProperty('rawOutput.executionSummary', { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + }); + } else { + expect(state.blocks[0]).toHaveProperty('rawOutput.tokenCount', 20); + expect(state.blocks[0]).toHaveProperty('rawOutput.executionSummary', { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + totalTokens: 120, + }); + } + }, + ); + it('omits root output when none of its fields are projected', () => { const [result] = projectMainTranscriptEventsForTesting([ { diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx index 97823608ef4..1b38390ab7b 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx @@ -1018,112 +1018,121 @@ describe('DaemonSessionProvider', () => { ]); }); - it('materializes an anchored turn outside the live transcript', async () => { - sdkMocks.capabilities.mockResolvedValue({ - workspaceCwd: '/mock-workspace', - features: ['session_turn_navigation'], - }); - const liveOverlapEvent = { - v: 1, - id: 2, - type: 'session_update', - data: { - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'Live prompt' }, - _meta: { - qwenTranscript: { sourceRecordIds: ['live-turn'] }, + it.each(['full', 'summary'] as const)( + 'materializes an anchored turn outside the live transcript in %s mode', + async (subagentTranscriptMode) => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_turn_navigation'], + }); + const liveOverlapEvent = { + v: 1, + id: 2, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Live prompt' }, + _meta: { + qwenTranscript: { sourceRecordIds: ['live-turn'] }, + }, }, }, - }, - } as DaemonEvent; - const session = createMockSession({ - sessionId: 'session-navigation', - replaySnapshot: { - compactedReplay: [], - liveJournal: [liveOverlapEvent], - }, - getTurnIndexPage: vi.fn(async () => ({ - v: 1 as const, + } as DaemonEvent; + const session = createMockSession({ sessionId: 'session-navigation', - snapshot: 'snapshot-1', - totalTurns: 1, - start: 0, - turns: [ - { - ordinal: 0, - turnId: 'turn-0', - kind: 'prompt' as const, - label: 'Historical prompt', - }, - ], - })), - getTranscriptPage: vi.fn(async () => ({ - v: 1 as const, - sessionId: 'session-navigation', - events: [ - { - v: 1, - id: 1, - type: 'session_update', - data: { - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'Historical prompt' }, - _meta: { - qwenTranscript: { sourceRecordIds: ['turn-0'] }, + replaySnapshot: { + compactedReplay: [], + liveJournal: [liveOverlapEvent], + }, + getTurnIndexPage: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-navigation', + snapshot: 'snapshot-1', + totalTurns: 1, + start: 0, + turns: [ + { + ordinal: 0, + turnId: 'turn-0', + kind: 'prompt' as const, + label: 'Historical prompt', + }, + ], + })), + getTranscriptPage: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-navigation', + events: [ + { + v: 1, + id: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Historical prompt' }, + _meta: { + qwenTranscript: { sourceRecordIds: ['turn-0'] }, + }, }, }, }, - }, - liveOverlapEvent, - ], - hasMore: false, - targetRecordId: 'turn-0', - hasOlder: false, - })), - }); - sdkMocks.sessions.push(session); - let navigationStore: - | ReturnType - | undefined; - let navigation: DaemonTurnNavigationSnapshot | undefined; + liveOverlapEvent, + ], + hasMore: false, + targetRecordId: 'turn-0', + hasOlder: false, + })), + }); + sdkMocks.sessions.push(session); + let navigationStore: + | ReturnType + | undefined; + let navigation: DaemonTurnNavigationSnapshot | undefined; - function Harness() { - navigationStore = useDaemonTurnNavigationStore(); - navigation = useDaemonTurnNavigationState(); - return null; - } + function Harness() { + navigationStore = useDaemonTurnNavigationStore(); + navigation = useDaemonTurnNavigationState(); + return null; + } - await renderWithProvider(, { autoConnect: true }); - await act(async () => { - await vi.waitFor(() => expect(navigation?.mode).toBe('ready')); - }); + await renderWithProvider(, { + autoConnect: true, + subagentTranscriptMode, + }); + await act(async () => { + await vi.waitFor(() => expect(navigation?.mode).toBe('ready')); + }); - let location: Awaited< - ReturnType< - ReturnType['locateOrdinal'] - > - >; - await act(async () => { - location = await navigationStore!.locateOrdinal(0); - }); + let location: Awaited< + ReturnType< + ReturnType['locateOrdinal'] + > + >; + await act(async () => { + location = await navigationStore!.locateOrdinal(0); + }); - expect(location!).toMatchObject({ - turnId: 'turn-0', - view: 'historical', - }); - expect(navigation?.historicalPages.size).toBe(1); - expect(navigation?.historicalRanges).toHaveLength(1); - const historicalPage = [...navigation!.historicalPages.values()][0]; - expect([...historicalPage!.recordIds]).toEqual(['turn-0']); - expect(navigation?.historicalRanges[0]?.newer).toEqual({ kind: 'live' }); - expect(session.getTranscriptPage).toHaveBeenCalledWith({ - atRecordId: 'turn-0', - snapshot: 'snapshot-1', - limit: 200, - }); - }); + expect(location!).toMatchObject({ + turnId: 'turn-0', + view: 'historical', + }); + expect(navigation?.historicalPages.size).toBe(1); + expect(navigation?.historicalRanges).toHaveLength(1); + const historicalPage = [...navigation!.historicalPages.values()][0]; + expect([...historicalPage!.recordIds]).toEqual(['turn-0']); + expect(navigation?.historicalRanges[0]?.newer).toEqual({ kind: 'live' }); + expect(session.getTranscriptPage).toHaveBeenCalledWith({ + atRecordId: 'turn-0', + snapshot: 'snapshot-1', + limit: 200, + ...(subagentTranscriptMode === 'summary' + ? { compactedReplayMode: 'summary' } + : {}), + }); + }, + ); it('does not rerender streaming state consumers for equivalent transcript updates', async () => { let store: DaemonTranscriptStore | undefined; @@ -6188,48 +6197,55 @@ describe('DaemonSessionProvider', () => { expect(streamingState).toBe('idle'); }); - it('sends image prompt content through the daemon action', async () => { - const turnComplete = createDeferred(); - const submitPrompt = vi.fn(async () => ({ - promptId: 'prompt-1', - lastEventId: 10, - })); - const session = createMockSession({ - submitPrompt, - events: createTurnCompleteEvents(turnComplete), - }); - sdkMocks.sessions.push(session); - let actions: DaemonSessionActions | undefined; - - function Harness() { - actions = useDaemonActions(); - return null; - } - - await renderWithProvider(, { autoConnect: true }); - const providerActions = actions; - if (!providerActions) throw new Error('actions were not initialized'); - - await act(async () => { - const promptResult = providerActions.sendPrompt('describe', { - optimisticUserMessage: false, - images: [{ data: 'base64-image', mimeType: 'image/png' }], + it.each(['full', 'summary'] as const)( + 'sends image prompt content through the daemon action in %s mode', + async (subagentTranscriptMode) => { + const turnComplete = createDeferred(); + const submitPrompt = vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })); + const session = createMockSession({ + submitPrompt, + events: createTurnCompleteEvents(turnComplete), }); - await flushPromises(); - turnComplete.resolve(); - await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' }); - }); + sdkMocks.sessions.push(session); + let actions: DaemonSessionActions | undefined; - expect(submitPrompt).toHaveBeenCalledWith( - { - prompt: [ - { type: 'text', text: 'describe' }, - { type: 'image', data: 'base64-image', mimeType: 'image/png' }, - ], - }, - expect.any(AbortSignal), - ); - }); + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + subagentTranscriptMode, + }); + const providerActions = actions; + if (!providerActions) throw new Error('actions were not initialized'); + + await act(async () => { + const promptResult = providerActions.sendPrompt('describe', { + optimisticUserMessage: false, + images: [{ data: 'base64-image', mimeType: 'image/png' }], + }); + await flushPromises(); + turnComplete.resolve(); + await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + expect(submitPrompt).toHaveBeenCalledWith( + { + eventDetailMode: subagentTranscriptMode, + prompt: [ + { type: 'text', text: 'describe' }, + { type: 'image', data: 'base64-image', mimeType: 'image/png' }, + ], + }, + expect.any(AbortSignal), + ); + }, + ); it('passes retry prompts through the daemon action', async () => { const turnComplete = createDeferred(); @@ -6265,6 +6281,7 @@ describe('DaemonSessionProvider', () => { expect(submitPrompt).toHaveBeenCalledWith( { + eventDetailMode: 'full', prompt: [{ type: 'text', text: 'retry this' }], retry: true, }, @@ -7869,7 +7886,7 @@ describe('DaemonSessionProvider', () => { expect(blocks).toMatchObject([{ kind: 'assistant', text: 'hello' }]); }); - it('requests summary live replay for summary transcript mode', async () => { + it('requests summary live and compacted replay for summary transcript mode', async () => { sdkMocks.sessions.push(createMockSession()); await renderWithProvider(null, { @@ -7883,7 +7900,10 @@ describe('DaemonSessionProvider', () => { expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledWith( expect.anything(), expect.any(String), - expect.objectContaining({ liveReplayMode: 'summary' }), + expect.objectContaining({ + liveReplayMode: 'summary', + compactedReplayMode: 'summary', + }), expect.any(String), ); }); @@ -18364,6 +18384,7 @@ describe('DaemonSessionProvider', () => { workspaceCwd: '/mock-workspace', timeoutMs: 70_000, liveReplayMode: 'summary', + compactedReplayMode: 'summary', }, expect.any(String), ); diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index 73f18310371..59bd5145eab 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -616,6 +616,11 @@ function projectSubagentToolUpdate( const subagentColor = boundedString(rawOutput?.['subagentColor'], 80); const taskDescription = boundedString(rawOutput?.['taskDescription'], 240); const status = boundedString(rawOutput?.['status'], 80); + const taskStatus = status ?? event.status; + const settled = + taskStatus === 'completed' || + taskStatus === 'failed' || + taskStatus === 'cancelled'; const executionMode = rawOutput?.['executionMode']; const terminateReason = boundedString(rawOutput?.['terminateReason'], 240); const skills = Array.isArray(rawOutput?.['skills']) @@ -652,31 +657,34 @@ function projectSubagentToolUpdate( ? { subagentSessionReady: rawOutput['subagentSessionReady'] } : {}), ...(terminateReason ? { terminateReason } : {}), - ...(typeof rawOutput['tokenCount'] === 'number' + ...(skills.length > 0 ? { skills } : {}), + ...(settled && typeof rawOutput['tokenCount'] === 'number' ? { tokenCount: rawOutput['tokenCount'] } : {}), - ...(skills.length > 0 ? { skills } : {}), ...(executionSummary ? { executionSummary: { + ...(settled + ? Object.fromEntries( + [ + 'inputTokens', + 'outputTokens', + 'thoughtTokens', + 'cachedTokens', + 'totalTokens', + ] + .filter( + (key) => typeof executionSummary[key] === 'number', + ) + .map((key) => [key, executionSummary[key]]), + ) + : {}), ...(typeof executionSummary['totalToolCalls'] === 'number' ? { totalToolCalls: executionSummary['totalToolCalls'] } : {}), ...(typeof executionSummary['totalDurationMs'] === 'number' ? { totalDurationMs: executionSummary['totalDurationMs'] } : {}), - ...(typeof executionSummary['outputTokens'] === 'number' - ? { outputTokens: executionSummary['outputTokens'] } - : {}), - ...(typeof executionSummary['inputTokens'] === 'number' - ? { inputTokens: executionSummary['inputTokens'] } - : {}), - ...(typeof executionSummary['cachedTokens'] === 'number' - ? { cachedTokens: executionSummary['cachedTokens'] } - : {}), - ...(typeof executionSummary['totalTokens'] === 'number' - ? { totalTokens: executionSummary['totalTokens'] } - : {}), }, } : {}), @@ -699,11 +707,7 @@ function projectSubagentToolUpdate( function projectMainTranscriptEvents(events: DaemonUiEvent[]): DaemonUiEvent[] { const projected: DaemonUiEvent[] = []; for (const event of events) { - if ( - 'parentToolCallId' in event && - event.parentToolCallId && - event.type !== 'assistant.usage' - ) { + if ('parentToolCallId' in event && event.parentToolCallId) { continue; } projected.push( @@ -1427,7 +1431,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { getTurnIndexPage: (options) => connectedSession.getTurnIndexPage(options), getTranscriptPage: (options) => - connectedSession.getTranscriptPage(options), + connectedSession.getTranscriptPage({ + ...options, + ...(subagentTranscriptModeRef.current === 'summary' + ? { compactedReplayMode: 'summary' as const } + : {}), + }), materializeTranscriptEvents: materializeNavigationTranscriptEvents, }, @@ -2123,7 +2132,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : {}), ...(!shouldResumeRequestedSession && subagentTranscriptModeRef.current === 'summary' - ? { liveReplayMode: 'summary' as const } + ? { + liveReplayMode: 'summary' as const, + compactedReplayMode: 'summary' as const, + } : {}), ...(historyPaginationSupported && (!restoreSessionId || restoreMode === 'load') && @@ -4651,6 +4663,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { settleRestoredActivePrompt: () => settleCurrentSessionRestoredPromptRef.current(), flushTranscript: () => flushCurrentTranscriptRef.current(), + getEventDetailMode: () => subagentTranscriptModeRef.current, resetCurrentSessionActivePrompt: () => { hasCurrentSessionActivePromptRef.current = () => false; settleCurrentSessionRestoredPromptRef.current = () => false; @@ -4854,6 +4867,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { let terminalFailure = false; try { const page = await activeSession.getTranscriptPage({ + ...(subagentTranscriptModeRef.current === 'summary' + ? { compactedReplayMode: 'summary' as const } + : {}), ...(history.cursor !== undefined ? { cursor: history.cursor } : history.beforeRecordId !== undefined diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 25a8c873907..cd0cad62a81 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -178,6 +178,25 @@ describe('context usage counter reconciliation', () => { }); describe('getConnectionAfterSessionClear', () => { + it('uses the current provider mode for mid-turn admission', async () => { + const session = { + ...createMockSession('session-a'), + enqueueMidTurnMessage: vi.fn(async () => ({ accepted: true })), + }; + let mode: 'full' | 'summary' = 'summary'; + const { actions } = createActionsHarness({ + session, + getEventDetailMode: () => mode, + }); + await actions.enqueueMidTurnMessage('one'); + mode = 'full'; + await actions.enqueueMidTurnMessage('two', { messageId: 'two' }); + expect(session.enqueueMidTurnMessage.mock.calls).toEqual([ + ['one', { eventDetailMode: 'summary' }], + ['two', { messageId: 'two', eventDetailMode: 'full' }], + ]); + }); + it.each(['sendPrompt', 'submitPrompt'] as const)( 'preserves declared text before host and attachment expansion through %s', async (method) => { @@ -199,6 +218,30 @@ describe('getConnectionAfterSessionClear', () => { }, ); + it.each(['sendPrompt', 'submitPrompt'] as const)( + 'uses the current provider detail mode through %s', + async (method) => { + const session = createMockSession('session-a'); + let mode: 'full' | 'summary' = 'full'; + const { actions } = createActionsHarness({ + session, + getEventDetailMode: () => mode, + }); + for (const nextMode of ['full', 'summary'] as const) { + mode = nextMode; + session.submitPrompt.mockClear(); + const pending = actions[method]('Review this'); + await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled()); + expect(session.submitPrompt).toHaveBeenCalledWith( + expect.objectContaining({ eventDetailMode: mode }), + ...(method === 'sendPrompt' ? [expect.any(AbortSignal)] : []), + ); + if (method === 'sendPrompt') await actions.cancel(); + await pending; + } + }, + ); + it('clears session fields for the session being detached', () => { const next = getConnectionAfterSessionClear( { @@ -3464,6 +3507,7 @@ describe('createDaemonSessionActions', () => { undefined, ); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text: 'look' }, { @@ -3504,6 +3548,7 @@ describe('createDaemonSessionActions', () => { expect(session.uploadAttachment).not.toHaveBeenCalled(); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [{ type: 'text', text: '/help' }], }); expect(store.appendLocalUserMessage).toHaveBeenCalledWith( @@ -3537,6 +3582,7 @@ describe('createDaemonSessionActions', () => { expect(session.uploadAttachment).not.toHaveBeenCalled(); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [{ type: 'text', text: '/summarize' }], }); }); @@ -3693,6 +3739,7 @@ describe('createDaemonSessionActions', () => { expect(session.uploadAttachment).toHaveBeenCalledOnce(); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text }, { @@ -3952,6 +3999,7 @@ describe('createDaemonSessionActions', () => { undefined, ); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text: '/price-sheet update these prices' }, { @@ -4002,6 +4050,7 @@ describe('createDaemonSessionActions', () => { ); expect(session.submitPrompt).toHaveBeenCalledWith( { + eventDetailMode: 'full', prompt: shouldUpload ? [ { type: 'text', text: '/price-sheet update' }, @@ -4049,6 +4098,7 @@ describe('createDaemonSessionActions', () => { undefined, ); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', @@ -4110,6 +4160,7 @@ describe('createDaemonSessionActions', () => { undefined, ); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', @@ -4166,6 +4217,7 @@ describe('createDaemonSessionActions', () => { }); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', @@ -4204,6 +4256,7 @@ describe('createDaemonSessionActions', () => { }); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', @@ -4244,6 +4297,7 @@ describe('createDaemonSessionActions', () => { // matching the legacy shape). expect(session.uploadAttachment).not.toHaveBeenCalled(); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text: 'look' }, { type: 'image', data: 'AQID' }, @@ -4304,6 +4358,7 @@ describe('createDaemonSessionActions', () => { }); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text: 'look' }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, @@ -4700,6 +4755,7 @@ describe('createDaemonSessionActions', () => { expect(session.uploadAttachment).not.toHaveBeenCalled(); expect(session.submitPrompt).toHaveBeenCalledWith({ + eventDetailMode: 'full', prompt: [ { type: 'text', text: 'look' }, { @@ -5055,6 +5111,7 @@ describe('createDaemonSessionActions', () => { // messageId-keyed idempotency and the reconciliation rings never match // if this hop drops the option. expect(session.enqueueMidTurnMessage).toHaveBeenCalledWith('follow up', { + eventDetailMode: 'full', messageId: 'stable-id', }); }); @@ -5558,6 +5615,7 @@ function createActionsHarness( setRestoreSessionId?: ReturnType; setRestoreSessionContext?: ReturnType; getDefaultSessionContext?: () => DaemonProductSessionContext | undefined; + getEventDetailMode?: () => 'full' | 'summary'; } = {}, ) { let connection: DaemonConnectionState = opts.connection ?? { @@ -5630,6 +5688,7 @@ function createActionsHarness( getDefaultSessionContext: opts.getDefaultSessionContext ?? (() => undefined), getConnection: () => connection, + getEventDetailMode: opts.getEventDetailMode ?? (() => 'full'), hasSessionActivePrompt: opts.hasSessionActivePrompt ?? (() => false), resetCurrentSessionActivePrompt: vi.fn(), restartEventStream: opts.restartEventStream ?? vi.fn(), diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 1c056364f13..de0e238381d 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -230,6 +230,7 @@ export interface CreateDaemonSessionActionsArgs { ) => Promise; getDefaultSessionContext: () => DaemonProductSessionContext | undefined; getConnection: () => DaemonConnectionState; + getEventDetailMode: () => 'full' | 'summary'; hasSessionActivePrompt: () => boolean; resetCurrentSessionActivePrompt: () => void; restartEventStream: (sessionId: string) => void; @@ -406,6 +407,7 @@ export function createDaemonSessionActions({ createDetachedStandaloneSession, getDefaultSessionContext, getConnection, + getEventDetailMode, hasSessionActivePrompt, resetCurrentSessionActivePrompt, restartEventStream, @@ -1273,6 +1275,7 @@ export function createDaemonSessionActions({ } const promptRequest: Record = { prompt: uploaded.content, + eventDetailMode: getEventDetailMode(), }; options?.onAdmissionStarted?.(); if (inputAnnotations || typeof options?.submittedPrompt === 'string') { @@ -1594,6 +1597,7 @@ export function createDaemonSessionActions({ } const promptRequest: Record = { prompt: uploaded.content, + eventDetailMode: getEventDetailMode(), }; if (inputAnnotations || typeof options?.submittedPrompt === 'string') { promptRequest['_meta'] = { @@ -2805,10 +2809,10 @@ export function createDaemonSessionActions({ try { const { onAdmissionStarted, ...requestOptions } = opts ?? {}; onAdmissionStarted?.(); - return await session.enqueueMidTurnMessage( - message, - opts ? requestOptions : undefined, - ); + return await session.enqueueMidTurnMessage(message, { + ...requestOptions, + eventDetailMode: getEventDetailMode(), + }); } catch (err) { if (opts?.messageId) throw err; // An abort is the designed settle-time cancel (the message stays in the