diff --git a/docs/design/web-shell-bounded-transcript-and-subagent-details.md b/docs/design/web-shell-bounded-transcript-and-subagent-details.md new file mode 100644 index 00000000000..ed82f807062 --- /dev/null +++ b/docs/design/web-shell-bounded-transcript-and-subagent-details.md @@ -0,0 +1,613 @@ +# Web Shell Bounded Transcript and On-Demand Subagent Details + +## Status + +Phase 1 is implemented with two deliberate differences from the target design +below. The Web Shell currently projects the main transcript in its client store +instead of requesting a server-side `projection=main` view, and subagent detail +uses legacy session routes resolved through the selected parent-session runtime +instead of separate workspace-prefixed routes. The remaining sections describe +the target architecture, not the complete shipped protocol. + +## Problem + +The Web Shell currently bounds rendered DOM nodes with virtual scrolling, but +the full loaded transcript still participates in several client-side costs: + +- transcript blocks and their indexes remain in React state; +- block-to-message conversion and derived views inspect the loaded history; +- streaming updates copy or rebuild structures whose cost grows with history; +- expanded or collapsed rows only change presentation, not retained data; +- subagent text and nested tool calls are folded into the parent agent tool as + `subContent` and `subTools`, even when the agent row is collapsed. + +This means DOM virtualization alone cannot make memory and update latency flat +for very long sessions. Subagents are especially expensive because one compact +row in the main transcript can retain a large, deeply nested execution trace. + +## Goals + +1. Keep Web Shell transcript memory and streaming update cost bounded as a + session grows. +2. Keep only a window of complete main-agent turns near the viewport, plus the + live tail and state required for active interactions. +3. Render subagents in the main transcript as compact status rows only. +4. Fetch and render a subagent's full transcript in the existing right-side + panel when the user opens it. +5. Preserve stable scrolling, reconnect behavior, permissions, nested agents, + and the ability to return to the live tail. +6. Preserve the complete authoritative transcript in daemon storage. + +## Non-goals + +- Deleting or compacting persisted session history. +- Changing what the model receives as conversation context. +- Making the browser hold a fake continuously measured scroll area for every + message in an arbitrarily large session. +- Adding an IndexedDB transcript cache in the first version. +- Paginating an individual subagent detail transcript in the first version. +- Redesigning the existing artifact/review panel chrome. +- Optimizing Markdown parsing or syntax highlighting in this change. + +## Current behavior + +### Main transcript + +The Web Shell requests bounded transcript pages and can prepend older history. +`MessageList` already preserves the scroll anchor while prepending and uses +`@tanstack/react-virtual` above a threshold. The transcript store nevertheless +has one logical block array. Its maximum-block trimming is a safety cap, not a +reloadable sliding window: it does not model gaps, independently cached pages, +or a newer-page cursor. + +The public transcript API supports a cursor and `beforeRecordId`. This is +sufficient to walk toward older history from a known tail, but it is not a +complete contract for moving both directions after newer pages have been +evicted. + +### Subagents + +Persisted and live child events carry `parentToolCallId`. The transcript +normalizer retains those blocks, and `transcriptToMessages` attaches child text +and tools to the parent agent tool. `SubAgentPanel` then hides or displays this +already-materialized content. Collapsing an agent therefore saves some DOM and +Markdown work, but not transcript, message, index, or payload memory. + +### Right-side panel + +The existing artifact panel already provides the desired right-side layout: +resizing, tabs, close behavior, and per-session panel state. The implementation +should add a subagent tab kind and content renderer rather than create another +competing side panel. + +## Design principles + +### The daemon remains authoritative + +The client may evict any completed historical page because the persisted +transcript remains available from the daemon. Eviction must never alter the +stored transcript or the model's context. + +### Main and subagent transcripts are separate projections + +The main transcript is not the complete execution trace. It is a projection +containing main-agent conversation blocks and compact root-subagent summaries. +Subagent detail is a separately addressable projection, keyed by the root agent +tool call ID. + +### Evict complete semantic units + +The client evicts completed main-agent turns and complete cached subagent +detail entries, not arbitrary individual blocks. Active tools, permissions, +agents, and the current turn are pinned until they reach a terminal state. + +### Stable IDs, never array positions + +Scroll anchors, expansion state, detail tabs, and page boundaries use persisted +record IDs, block IDs, tool call IDs, and turn IDs. An array index is not a +stable identity after a prepend, eviction, replay, or branch change. + +## Proposed user experience + +### Main transcript + +- The newest window behaves as it does today and follows streaming output when + the user is at the bottom. +- Scrolling near the top loads an older main-agent window. +- If the user remains in history, pages farthest from the viewport are evicted. +- New output continues in a separately pinned live tail. The UI shows a new + activity count and a **Back to latest** action instead of moving the user's + reading position. +- When a discontinuity exists below the current historical window, the bottom + sentinel loads newer pages or jumps directly to the live tail. +- The application does not pretend that unloaded turns have exact pixel + heights. Gaps are explicit loading boundaries. + +### Subagents + +The main transcript shows one compact row per root subagent: + +- agent type and short task description; +- pending, running, completed, failed, or waiting-for-approval state; +- elapsed time; +- tool count and token count when available; +- a short failure or termination reason when applicable; +- an **Open details** affordance. + +The main row does not render or retain child thinking, child messages, nested +tool output, or the full subagent result. Clicking it opens a subagent tab in +the right-side panel. The panel fetches the detail projection and renders: + +- the subagent result; +- chronological child text and tool calls; +- nested subagents; +- loading, unavailable, and partial-history states; +- live updates while the detail tab is open. + +The detail never expands inside the message flow. On a regular viewport wider +than 1000px the existing right panel remains docked and resizable. On narrower +viewports, and whenever the app is showing split sessions, the same panel and +tab content is hosted in a right-edge floating drawer so opening details does +not shrink or reflow the transcript panes. + +Closing the tab releases an over-budget detailed transcript immediately; +smaller completed details may remain in the short bounded cache. Reopening it +fetches the complete detail again if it has been evicted. + +## Data model + +### Main transcript window + +The Web Shell should replace the single conceptual historical block list with +a page table plus a small mutable live tail: + +```ts +interface MainTranscriptWindow { + pages: MainTranscriptPage[]; + liveTail: LiveTranscriptPage; + olderBoundary?: TranscriptBoundary; + newerBoundary?: TranscriptBoundary; + viewportAnchor?: ScrollAnchor; +} + +interface MainTranscriptPage { + id: string; + blocks: readonly DaemonTranscriptBlock[]; + firstRecordId: string; + lastRecordId: string; + byteSize: number; + turnIds: readonly string[]; +} + +interface TranscriptBoundary { + cursor?: string; + recordId?: string; + hasMore: boolean; +} + +interface ScrollAnchor { + blockId: string; + offsetPx: number; +} +``` + +Historical pages are immutable. Only `liveTail` accepts streaming mutations. +When a turn finishes, its live blocks become immutable historical data. A live +delta must not copy every historical page. + +Pages may be fetched in record-sized chunks, but eviction only removes a range +whose turns are complete within the client. A boundary turn split across two +responses remains pinned until its neighboring response is available. + +### Subagent summary + +The main projection needs a normalized, bounded summary instead of carrying a +large `rawOutput` object: + +```ts +interface DaemonSubagentSummary { + toolCallId: string; + parentToolCallId?: string; + subagentType?: string; + description?: string; + status: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'waiting-for-approval'; + startedAt?: number; + endedAt?: number; + toolCount?: number; + tokenCount?: number; + terminationReason?: string; + requiresAction?: boolean; + hasDetails: boolean; +} +``` + +String fields must have daemon-enforced size limits. The summary must not carry +the prompt, result, child tool arguments, child tool output, or child text. +Root subagents appear in the main projection. Nested subagents appear only in +their root detail projection. + +### Subagent detail cache + +```ts +interface SubagentDetailEntry { + sessionId: string; + rootToolCallId: string; + status: 'idle' | 'loading' | 'ready' | 'partial' | 'error'; + blocks: readonly DaemonTranscriptBlock[]; + byteSize: number; + lastAccessedAt: number; +} +``` + +The cache is bounded by both entry count and bytes. Open tabs and active +subagents are pinned. Closed completed entries are least-recently-used eviction +candidates. Detail state must not be inserted back into the main transcript +window. + +## Daemon and SDK protocol + +### Main transcript projection + +Extend transcript reads with an explicit projection: + +```text +GET /session/:id/transcript?projection=main +GET /workspaces/:workspace/session/:id/transcript?projection=main +``` + +`projection=full` remains the compatibility default for existing consumers. +Web Shell opts into `projection=main`. + +The main projection: + +- includes top-level user, assistant, thought, tool, permission, status, and + shell events; +- includes bounded root-subagent lifecycle summaries; +- excludes events whose `parentToolCallId` belongs to a subagent execution; +- strips large subagent result/detail fields from the root tool event; +- preserves source record IDs and ordering required for pagination. + +Projection must happen before response serialization. Fetching full history and +discarding child events in React is not an acceptable lazy-loading design. +The page limit counts records visible in the selected projection, not raw +records inspected by the reader. The transcript index therefore needs a +projection-aware record sequence (or an equivalent skip index). Otherwise one +large subagent could produce a long run of empty `projection=main` pages. + +Permission and other control-plane events are not hidden merely because their +originating tool belongs to a subagent. A pending child approval remains in the +session's bounded sidechannel state, and the root summary exposes a bounded +`requiresAction` indication. The main row can open the relevant detail tab; +existing global approval UI remains actionable without loading historical +child output. + +### Bidirectional main pagination + +Add a fresh-snapshot forward anchor alongside the existing backward anchor: + +```ts +interface DaemonSessionTranscriptPageOptions { + cursor?: string; + beforeRecordId?: string; + afterRecordId?: string; + limit?: number; + projection?: 'full' | 'main'; + clientId?: string; +} +``` + +Exactly one of `cursor`, `beforeRecordId`, or `afterRecordId` may be supplied. +The response should expose the first and last returned record IDs, and whether +older and newer records exist. Opaque cursors remain snapshot-bound; an anchor +request creates a fresh snapshot so the client can recover from an expired +cursor. + +The live event stream covers records appended after the loaded tail. On +reconnect or cursor expiry, the Web Shell discards affected page cursors and +re-anchors by the last retained persisted record ID. + +The projection is a response view only. It must not mutate the authoritative +event or remove the subagent result that core/model execution consumes. + +### Subagent detail endpoint + +Add workspace-scoped and legacy-primary routes: + +```text +GET /session/:id/subagents/:toolCallId/transcript +GET /workspaces/:workspace/session/:id/subagents/:toolCallId/transcript +``` + +The workspace-scoped route must resolve the same selected runtime and trust +boundary as the corresponding session transcript route. It must never fall +back to the primary runtime when the workspace or session owner is ambiguous, +unavailable, draining, or removed. + +The endpoint returns the root subagent's complete ordered descendant event set +in one response, including nested subagents. It has no page cursor in the first +version. Descendant membership is the transitive closure of +`parentToolCallId`, not a text match and not adjacency in the file. The daemon +transcript index should maintain the parent-to-child record relationship so +opening one old subagent does not linearly replay the entire session. + +```ts +interface DaemonSubagentTranscript { + v: 1; + sessionId: string; + rootToolCallId: string; + events: DaemonEvent[]; + replayBoundary?: number; + partial?: true; + replayError?: string; +} +``` + +This deliberately differs from main-transcript pagination: users browse the +main history incrementally, while opening a subagent opts into loading that one +execution in full. A very large open subagent may therefore exceed the normal +detail-cache byte target. The open entry remains pinned for correctness and is +released when its tab closes instead of being partially truncated. + +The response must distinguish: + +- unknown tool call ID; +- a known root with no persisted details; +- partial/corrupt transcript detail; +- detail that was valid but became unavailable after transcript rewriting. + +### Live detail + +In the first implementation, the existing live session stream may continue to +carry child events, but the Web Shell routes them directly to an open detail +entry and drops them when no corresponding detail is open. They must never +enter the main window reducer. The detail snapshot response includes a replay +boundary so buffered live events can be merged without a race, duplicate, or +gap. + +This first step removes the dominant React-state and retained-memory cost but +does not remove child-event network and JSON-decoding cost. If measurement +shows that active subagents still produce material transport overhead, add a +separate per-client detail subscription in a later protocol change. That +optimization is deliberately not required for the first version. + +## Client rendering + +### Main subagent row + +`ToolLine` should render the summary row and call an injected +`onOpenSubagent(toolCallId)` handler. It must no longer mount `SubAgentPanel` in +the main transcript. Approval indicators for a child tool remain visible on +the root summary row; opening the detail panel reveals the approving tool. + +The click behavior should open details, not toggle a large inline accordion. +The row can retain a small disclosure icon only if it clearly represents the +right panel. Existing inline expansion state for agent rows is removed from the +main transcript. + +### Right panel integration + +Add a `subagent` variant to the existing right-panel tab union. The tab stores +only session ID, root tool call ID, title, and the latest summary. Its content +component owns the detail fetch and uses the existing transcript conversion +and tool rendering primitives in detail mode. + +Do not copy detail blocks into the artifact list and do not encode them into +the tab object. The tab is an identity and navigation record, not a data cache. + +Opening the same subagent again focuses its existing tab. Switching sessions +uses the panel's current per-session state behavior. A tab restored for a +session refetches details if its cache entry is no longer present. + +### Expansion state + +Any expandable row inside subagent details is keyed by stable tool call or +block ID. Component-local expansion state may be lost when a detail cache entry +is evicted; this is acceptable for the first version and should be documented +as such. Main transcript turn-collapse state must remain independent from +subagent detail state. + +## Memory policy + +Use both item and byte budgets. A count-only limit is unsafe because one tool +result or Markdown block can be much larger than thousands of ordinary rows. + +Initial defaults should be selected by benchmark rather than treated as API, +but the starting test configuration is: + +- main historical window: 100 completed turns; +- minimum context around viewport: two fetched pages on each side; +- main normalized payload target: 16 MiB; +- closed subagent detail cache: at most three entries and 16 MiB total; +- active turn, active approvals, running tools, and open detail tabs: pinned. + +When pinned content alone exceeds a budget, correctness wins: keep it, record a +diagnostic metric, and resume eviction after it becomes terminal. The UI must +not silently truncate an active tool or permission request. + +## Scroll anchoring + +Before a prepend, page removal, expansion, or detail-panel resize, record the +first visible stable block ID and its offset from the scroller top. After the +layout change, restore that block to the same offset. Cache measured heights by +block ID only as a rendering aid; correctness must not depend on an estimate +for an unloaded turn. + +Eviction must not remove the page containing the anchor or the configured +neighboring pages. If the anchor record was invalidated by a branch rewrite, +fall back to the closest retained record and show a non-blocking history +refresh notice. + +## Derived features and gaps + +Several existing features assume that `messages` contains all loaded history. +They need explicit window semantics: + +- session timeline lists loaded turns plus older/newer loading boundaries; +- scroll-to-message first checks the window, then requests a page containing + the persisted record if a locator is available; +- Todo and plan floating state is maintained as a small session-level snapshot + and is not recomputed only from the visible window; +- usage totals come from daemon session metadata or an accumulated summary, + not from summing only visible messages; +- branch and rewind actions use daemon-owned record identities and can request + the containing page before showing a target; +- search must either be explicitly limited to loaded content or use a daemon + search/locator API; it must not imply that an unloaded result does not exist. + +The first delivery may label timeline and search as “loaded messages” if a +server-side locator is deferred, but branch, rewind, approval, and active-task +correctness cannot be deferred. + +## Failure handling + +- A failed older/newer page request leaves the current window unchanged and + exposes a retry sentinel. +- A failed subagent detail request leaves the compact main summary usable and + shows retry in the right panel. +- Duplicate events are deduplicated by persisted record/event identity. +- Out-of-order live child events are buffered until their parent mapping is + known, within a strict bound; overflow triggers a detail refetch. +- An expired snapshot cursor re-anchors by a retained record ID. +- If the daemon is offline, already-loaded windows remain readable; evicted + pages and details are reported as temporarily unavailable. + +## Delivery plan + +### Phase 1: main/detail projection boundary + +1. Add bounded subagent summary types and the `projection=main` transcript + response. +2. Add the subagent detail route and transcript child index. +3. Split child events from the main reducer in Web Shell. +4. Replace inline `SubAgentPanel` expansion with the compact summary row. +5. Add the subagent tab to the existing right panel and load detail on demand. + +This phase provides a meaningful memory improvement even before the main turn +window is fully sliding, because subagent traces no longer inflate the main +block and message graphs. + +### Phase 2: bounded main window + +1. Add forward anchoring and bidirectional page metadata. +2. Introduce immutable historical pages and a separate mutable live tail. +3. Evict complete turns by combined turn and byte budgets. +4. Add older/newer sentinels, detached-live status, and jump-to-latest. +5. Make timeline, Todo/plan, jump, branch, and rewind gap-aware. + +### Phase 3: measured follow-ups + +- Add a filtered live subagent subscription only if transport profiling shows + material remaining cost. +- Add server-side search/locate-by-message if unloaded-history navigation is a + frequent workflow. +- Tune budgets from real heap and interaction measurements. + +## Verification + +### Correctness tests + +- The main projection contains one bounded summary for each root subagent and + no descendant text, tool arguments, output, or nested tool blocks. +- Opening detail returns the exact descendant tree for the selected root and + never includes a concurrent sibling subagent. +- Nested subagents preserve parent-child order and identity. +- Pending child approval pins the root summary and remains actionable. +- Snapshot plus buffered live events has no missing or duplicate child event. +- An evicted completed turn reloads with identical stable IDs and content. +- No page eviction splits an active or incomplete turn. +- Scrolling older and newer across repeated eviction cycles preserves order. +- Reconnect, branch rewrite, transcript gaps, and expired cursors recover using + their declared behavior. +- Workspace-scoped detail requests never cross into another runtime. + +### UI tests + +- Agent rows never mount detailed Markdown or nested tool rows in the main + transcript. +- Clicking an agent opens or focuses the correct right-panel tab. +- Loading, partial, error, retry, running, completed, and failed states render + correctly. +- Reading old history while new output streams does not move the viewport. +- Prepend, append, eviction, turn expansion, and panel resize preserve the + visible anchor within two pixels in deterministic DOM tests. +- Closing detail releases unpinned detail state according to the cache policy. + +### Performance tests + +Create deterministic fixtures containing ordinary long conversations, a few +very large tool results, many small subagents, and one deeply nested large +subagent. Measure at 1,000, 10,000, 50,000, and 200,000 persisted blocks: + +- retained JavaScript heap after forced GC; +- main transcript block/message counts; +- DOM node count; +- p50 and p95 live-delta reducer time; +- React commit duration while streaming; +- time to open cached and uncached subagent details; +- bytes transferred for main transcript pages; +- scroll-anchor error and long tasks. + +Acceptance criteria: + +- main Web Shell heap reaches a stable plateau once the configured window is + full, excluding explicitly pinned active content; +- main transcript retained block and DOM counts stay within configured bounds; +- live-delta reducer p95 does not grow materially with persisted session size; +- main projection payload does not grow with hidden subagent detail size; +- opening detail cost scales with the selected subagent, not the full session; +- there are no duplicate, missing, cross-session, or cross-subagent records. + +## Risks and mitigations + +| Risk | Mitigation | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| A collapsed row still retains full data | Project and filter on the daemon before serialization; never treat CSS collapse as lazy loading. | +| Child approval disappears from main UI | Pin active child state and reflect it in the root summary. | +| Detail snapshot races live events | Return a replay boundary and merge buffered events by stable identity. | +| Newer history cannot be restored after eviction | Add `afterRecordId` and fresh-snapshot anchoring before enabling bidirectional eviction. | +| Scroll jumps after page removal | Preserve a stable block anchor and never evict its neighboring pages. | +| Nested agent records leak into a sibling | Build descendant closure from indexed `parentToolCallId` relationships and test concurrent roots. | +| Byte budget is exceeded by active work | Pin for correctness, emit diagnostics, and evict after completion. | +| Existing SDK consumers change behavior | Keep `projection=full` as the compatibility default; Web Shell opts in. | +| Right panel becomes two competing systems | Extend the existing tab union and panel chrome rather than add another side panel. | + +## Alternatives considered + +### Only virtualize DOM rows + +Rejected as the long-session solution. It bounds mounted DOM but retains the +full transcript, derived messages, subagent trees, and indexes. + +### Keep inline subagent expansion but render lazily + +Useful only as a small rendering optimization. If `subContent` and `subTools` +remain attached to the main message graph, memory and update costs remain. + +### Fetch the full transcript and filter subagents in the browser + +Rejected. It pays the network, parsing, normalization, and transient-memory +cost that on-demand detail is intended to avoid. + +### Create a second dedicated side panel + +Rejected. The existing right-side tabbed panel already supplies the necessary +layout and session behavior. + +## Decision summary + +The target architecture combines two independent bounds: + +1. a page-based, bidirectional main-agent transcript window with an isolated + live tail; and +2. a summary-only main representation of subagents whose complete descendant + transcripts are fetched into a bounded right-panel cache on demand. + +Implement the subagent projection boundary first, then the general sliding +window. This order removes one of the largest and least splittable sources of +main-transcript growth while establishing the projection and cache boundaries +needed by the complete long-session design. diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index 2fcccb3a8cf..147a31cc259 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -55,27 +55,27 @@ Creates a new query session with the Qwen Code. #### QueryOptions -| Option | Type | Default | Description | -| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | -| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | -| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | -| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | -| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | -| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | -| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | -| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | -| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | -| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | -| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | -| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | -| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | -| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | -| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | -| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | -| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | +| Option | Type | Default | Description | +| ------------------------ | -------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | +| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!note] > For `coreTools`, aliases like `Read`, `Edit`, and `Bash` also work, but invocation specifiers such as `Bash(git *)` are stripped. `coreTools` restricts tool registration, not invocation patterns. diff --git a/docs/users/integration-github-action.md b/docs/users/integration-github-action.md index 5edcfd9e246..cc1812c1786 100644 --- a/docs/users/integration-github-action.md +++ b/docs/users/integration-github-action.md @@ -112,32 +112,32 @@ This type of action can be used to invoke a general-purpose, conversational Qwen -- qwen_api_key: _(Optional)_ The API key for the Qwen API. +- qwen*api_key: *(Optional)\_ The API key for the Qwen API. -- qwen_cli_version: _(Optional, default: `latest`)_ The version of the Qwen Code CLI to install. Can be "latest", "preview", "nightly", a specific version number, or a git branch, tag, or commit. For more information, see [Qwen Code CLI releases](https://github.com/QwenLM/qwen-code-action/blob/main/docs/releases.md). +- qwen*cli_version: *(Optional, default: `latest`)\_ The version of the Qwen Code CLI to install. Can be "latest", "preview", "nightly", a specific version number, or a git branch, tag, or commit. For more information, see [Qwen Code CLI releases](https://github.com/QwenLM/qwen-code-action/blob/main/docs/releases.md). -- qwen_debug: _(Optional)_ Enable debug logging and output streaming. +- qwen*debug: *(Optional)\_ Enable debug logging and output streaming. -- qwen_model: _(Optional)_ The model to use with Qwen Code. +- qwen*model: *(Optional)\_ The model to use with Qwen Code. - prompt: _(Optional, default: `You are a helpful assistant.`)_ A string passed to the Qwen Code CLI's [`--prompt` argument](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/configuration.md#command-line-arguments). - settings: _(Optional)_ A JSON string written to `.qwen/settings.json` to configure the CLI's _project_ settings. For more details, see the documentation on [settings files](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/configuration.md#settings-files). -- use_qwen_code_assist: _(Optional, default: `false`)_ Whether to use Code Assist for Qwen Code model access instead of the default Qwen Code API key. +- use*qwen_code_assist: *(Optional, default: `false`)\_ Whether to use Code Assist for Qwen Code model access instead of the default Qwen Code API key. For more information, see the [Qwen Code CLI documentation](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/authentication.md). -- use_vertex_ai: _(Optional, default: `false`)_ Whether to use Vertex AI for Qwen Code model access instead of the default Qwen Code API key. +- use*vertex_ai: *(Optional, default: `false`)\_ Whether to use Vertex AI for Qwen Code model access instead of the default Qwen Code API key. For more information, see the [Qwen Code CLI documentation](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/authentication.md). - extensions: _(Optional)_ A list of Qwen Code CLI extensions to install. -- upload_artifacts: _(Optional, default: `false`)_ Whether to upload artifacts to the github action. +- upload*artifacts: *(Optional, default: `false`)\_ Whether to upload artifacts to the github action. -- use_pnpm: _(Optional, default: `false`)_ Whether or not to use pnpm instead of npm to install qwen-code-cli +- use*pnpm: *(Optional, default: `false`)\_ Whether or not to use pnpm instead of npm to install qwen-code-cli -- workflow_name: _(Optional, default: `${{ github.workflow }}`)_ The GitHub workflow name, used for telemetry purposes. +- workflow*name: *(Optional, default: `${{ github.workflow }}`)\_ The GitHub workflow name, used for telemetry purposes. diff --git a/package-lock.json b/package-lock.json index 2e89fcd414e..699b6912981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26870,6 +26870,19 @@ "node": ">= 0.8" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -31223,7 +31236,8 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "shiki": "^1.29.2", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "vaul": "^1.1.2" }, "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 214dfb2b673..f10613c5bbb 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -618,6 +618,8 @@ export interface ServeSessionAgentTaskStatus { stats?: { totalTokens: number; toolUses: number; durationMs: number }; recentActivities?: Array<{ name: string; description: string; at: number }>; prompt?: string; + /** Tool call in the parent session that launched this agent. */ + toolUseId?: string; /** * `id` of the agent task that spawned this one; absent for agents * launched by the top-level session. Mirrors `AgentTask.parentAgentId` diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts index fcb5b411d94..3255674862b 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts @@ -81,4 +81,9 @@ describe('buildSessionTasksStatus agent lineage', () => { ]); expect(task.depth).toBe(0); }); + + it('exposes the parent tool call that launched an agent', () => { + const [task] = serializedAgents([agentTask({ toolUseId: 'call-1' })]); + expect(task.toolUseId).toBe('call-1'); + }); }); diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts index afe3fca662c..a23c5ad579b 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -71,6 +71,7 @@ function serializeAgentTask( } : {}), ...optionalField('prompt', entry.prompt), + ...optionalField('toolUseId', entry.toolUseId), }; } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 7ebdc7e86cd..c77a2f72e58 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -82,6 +82,10 @@ import { createSessionOrganizationService } from '../session-organization-helper import { replayTranscriptRecordPage } from '../../acp-integration/session/history-replay-page.js'; import { GENERATION_MAX_PROMPT_BYTES } from '../../acp-integration/generation.js'; import { requireSessionRuntime } from './session-runtime.js'; +import { + parseVirtualSubagentSessionId, + type VirtualSubagentSessions, +} from '../virtual-subagent-sessions.js'; import { resolveWorkspaceRuntimeFromParam, sendUntrustedWorkspaceResponse, @@ -102,6 +106,7 @@ interface RegisterSessionRoutesDeps { promptDeadlineMs?: number; sessionShellCommandEnabled: boolean; languageCodes: string[]; + virtualSubagentSessions?: VirtualSubagentSessions; } const WORKSPACE_TRANSCRIPT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; @@ -405,6 +410,7 @@ export function registerSessionRoutes( daemonLog, promptDeadlineMs, sessionShellCommandEnabled, + virtualSubagentSessions, } = deps; const LANGUAGE_CODES = deps.languageCodes; const transcriptCursorMasterKey = crypto.randomBytes(32); @@ -1469,6 +1475,55 @@ export function registerSessionRoutes( (action: 'load' | 'resume') => async (req: Request, res: Response) => { const sessionId = requireSessionId(req, res); if (!sessionId) return; + const virtualKey = parseVirtualSubagentSessionId(sessionId); + if (virtualKey) { + const route = `POST /session/:id/${action}`; + if (action !== 'load') { + res.status(400).json({ + error: `Virtual subagent sessions do not support ${action}`, + code: 'unsupported_action', + sessionId, + }); + return; + } + if (!virtualSubagentSessions) { + res.status(404).json({ + error: `No session with id "${sessionId}"`, + code: 'session_not_found', + sessionId, + }); + return; + } + const runtime = requireSessionRuntime({ + sessionId: virtualKey.parentSessionId, + route, + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + try { + const session = await virtualSubagentSessions.load( + runtime, + sessionId, + clientId, + ); + if (!session) { + res.status(404).json({ + error: 'Subagent session not found', + code: 'session_not_found', + sessionId, + }); + return; + } + res.status(200).json(session); + } catch (err) { + sendBridgeError(res, err, { route, sessionId }); + } + return; + } const body = safeBody(req); const route = `POST /session/:id/${action}`; let resolvedRuntime: @@ -1684,6 +1739,116 @@ export function registerSessionRoutes( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); + app.get('/session/:id/subagents/:toolCallId', async (req, res) => { + const route = 'GET /session/:id/subagents/:toolCallId'; + const sessionId = requireSessionId(req, res); + if (!sessionId) return; + if (!virtualSubagentSessions) { + res.status(404).json({ + error: `No session with id "${sessionId}"`, + code: 'session_not_found', + sessionId, + }); + return; + } + const toolCallId = req.params['toolCallId']; + if (!toolCallId || toolCallId.length > 500) { + res.status(400).json({ + error: '`toolCallId` must be a non-empty tool call id', + code: 'invalid_tool_call_id', + }); + return; + } + const runtime = requireSessionRuntime({ + sessionId, + route, + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + try { + const resolved = await virtualSubagentSessions.resolve( + runtime, + sessionId, + toolCallId, + ); + if (!resolved) { + res.status(404).json({ + error: 'Subagent session not found', + code: 'session_not_found', + sessionId, + toolCallId, + }); + return; + } + res.status(200).set('Cache-Control', 'no-store').json(resolved); + } catch (err) { + sendBridgeError(res, err, { route, sessionId }); + } + }); + + app.post( + '/session/:id/subagents/:toolCallId/cancel', + mutate(), + async (req, res) => { + const route = 'POST /session/:id/subagents/:toolCallId/cancel'; + const sessionId = requireSessionId(req, res); + if (!sessionId) return; + if (!virtualSubagentSessions) { + res.status(404).json({ + error: `No session with id "${sessionId}"`, + code: 'session_not_found', + sessionId, + }); + return; + } + const toolCallId = req.params['toolCallId']; + if (!toolCallId || toolCallId.length > 500) { + res.status(400).json({ + error: '`toolCallId` must be a non-empty tool call id', + code: 'invalid_tool_call_id', + }); + return; + } + const runtime = requireSessionRuntime({ + sessionId, + route, + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + try { + const resolved = await virtualSubagentSessions.resolve( + runtime, + sessionId, + toolCallId, + ); + if (!resolved) { + res.status(404).json({ + error: 'Subagent session not found', + code: 'session_not_found', + sessionId, + toolCallId, + }); + return; + } + res + .status(200) + .json( + await runtime.bridge.cancelSessionTask( + sessionId, + resolved.taskId, + 'agent', + ), + ); + } catch (err) { + sendBridgeError(res, err, { route, sessionId }); + } + }, + ); + app.post( '/session/:id/branch', mutate(), @@ -2017,6 +2182,30 @@ export function registerSessionRoutes( app.get( '/session/:id/context', + (req, res, next) => { + const sessionId = req.params['id']; + const key = sessionId + ? parseVirtualSubagentSessionId(sessionId) + : undefined; + if (!sessionId || !key) { + next(); + return; + } + const runtime = requireSessionRuntime({ + sessionId: key.parentSessionId, + route: 'GET /session/:id/context', + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + res.status(200).json({ + v: 1, + sessionId, + workspaceCwd: runtime.workspaceCwd, + state: {}, + }); + }, withOwnerReadSession( 'GET /session/:id/context', async (_req, res, sessionId, runtime) => { @@ -2055,6 +2244,30 @@ export function registerSessionRoutes( app.get( '/session/:id/supported-commands', + (req, res, next) => { + const sessionId = req.params['id']; + const key = sessionId + ? parseVirtualSubagentSessionId(sessionId) + : undefined; + if (!sessionId || !key) { + next(); + return; + } + const runtime = requireSessionRuntime({ + sessionId: key.parentSessionId, + route: 'GET /session/:id/supported-commands', + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + res.status(200).json({ + v: 1, + sessionId, + availableCommands: [], + availableSkills: [], + }); + }, withOwnerReadSession( 'GET /session/:id/supported-commands', async (_req, res, sessionId, runtime) => { @@ -2521,6 +2734,31 @@ export function registerSessionRoutes( app.post( '/session/:id/heartbeat', mutate(), + (req, res, next) => { + const sessionId = req.params['id']; + const key = sessionId + ? parseVirtualSubagentSessionId(sessionId) + : undefined; + if (!sessionId || !key) { + next(); + return; + } + const runtime = requireSessionRuntime({ + sessionId: key.parentSessionId, + route: 'POST /session/:id/heartbeat', + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res.status(200).json({ + sessionId, + ...(clientId ? { clientId } : {}), + lastSeenAt: Date.now(), + }); + }, withOwnerMutableSession( 'POST /session/:id/heartbeat', (req, res, sessionId, runtime) => { @@ -2538,6 +2776,27 @@ export function registerSessionRoutes( app.post( '/session/:id/detach', mutate(), + (req, res, next) => { + const sessionId = req.params['id']; + const key = sessionId + ? parseVirtualSubagentSessionId(sessionId) + : undefined; + if (!sessionId || !key) { + next(); + return; + } + const runtime = requireSessionRuntime({ + sessionId: key.parentSessionId, + route: 'POST /session/:id/detach', + res, + workspaceRegistry, + daemonLog, + }); + if (!runtime) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res.status(204).end(); + }, withOwnerMutableSession( 'POST /session/:id/detach', async (req, res, sessionId, runtime) => { diff --git a/packages/cli/src/serve/routes/sse-events.ts b/packages/cli/src/serve/routes/sse-events.ts index 3b8790119bc..88e34c36791 100644 --- a/packages/cli/src/serve/routes/sse-events.ts +++ b/packages/cli/src/serve/routes/sse-events.ts @@ -23,6 +23,10 @@ import { } from '../server/request-helpers.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; import { requireSessionRuntime } from './session-runtime.js'; +import { + parseVirtualSubagentSessionId, + type VirtualSubagentSessions, +} from '../virtual-subagent-sessions.js'; let activeSseCount = 0; @@ -36,6 +40,7 @@ interface RegisterSseEventsRoutesDeps { daemonLog?: DaemonLogger; writerIdleTimeoutMs?: number; sendBridgeError: SendBridgeError; + virtualSubagentSessions?: VirtualSubagentSessions; } type OmitId = Omit; @@ -82,7 +87,7 @@ export function registerSseEventsRoutes( const { workspaceRegistry, daemonLog, sendBridgeError, writerIdleTimeoutMs } = deps; - app.get('/session/:id/events', (req, res) => { + app.get('/session/:id/events', async (req, res) => { const sessionId = req.params['id']; const lastEventId = parseLastEventId(req.headers['last-event-id']); const maxQueued = parseMaxQueuedQuery(req.query['maxQueued'], res); @@ -95,8 +100,9 @@ export function registerSseEventsRoutes( let iter: AsyncIterator | undefined; const abort = new AbortController(); try { + const virtualKey = parseVirtualSubagentSessionId(sessionId); const runtime = requireSessionRuntime({ - sessionId, + sessionId: virtualKey?.parentSessionId ?? sessionId, route: 'GET /session/:id/events', res, workspaceRegistry, @@ -104,12 +110,26 @@ export function registerSseEventsRoutes( }); if (!runtime) return; const snapshot = req.query['snapshot'] === '1'; - const iterable = runtime.bridge.subscribeEvents(sessionId, { - signal: abort.signal, - lastEventId, - ...(maxQueued !== undefined ? { maxQueued } : {}), - ...(snapshot ? { snapshot: true } : {}), - }); + const iterable = virtualKey + ? await deps.virtualSubagentSessions?.subscribe(runtime, sessionId, { + signal: abort.signal, + lastEventId, + ...(maxQueued !== undefined ? { maxQueued } : {}), + }) + : runtime.bridge.subscribeEvents(sessionId, { + signal: abort.signal, + lastEventId, + ...(maxQueued !== undefined ? { maxQueued } : {}), + ...(snapshot ? { snapshot: true } : {}), + }); + if (!iterable) { + res.status(404).json({ + error: 'Subagent session not found', + code: 'session_not_found', + sessionId, + }); + return; + } iter = iterable[Symbol.asyncIterator](); } catch (err) { // `EventBus` throws `SubscriberLimitExceededError` when the diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 34db8b145da..5b36be6d756 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -151,6 +151,10 @@ import { resetTrustedFoldersForTesting, TRUSTED_FOLDERS_FILENAME, } from '../config/trustedFolders.js'; +import { + createVirtualSubagentSessionId, + VirtualSubagentSessions, +} from './virtual-subagent-sessions.js'; // ── Worktree mock infrastructure ──────────────────────────────────── // GitWorktreeService's constructor calls simpleGit() which validates @@ -7411,6 +7415,142 @@ describe('createServeApp', () => { ]); }); + it('resolves and cancels a virtual subagent through its routes', async () => { + const bridge = fakeBridge({ + cancelSessionTaskImpl: async () => ({ cancelled: true }), + }); + const resolveSpy = vi + .spyOn(VirtualSubagentSessions.prototype, 'resolve') + .mockResolvedValue({ + sessionId: createVirtualSubagentSessionId('s-1', 'agent-1'), + taskId: 'agent-1', + title: 'Investigate', + status: 'running', + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const resolveRes = await request(app) + .get('/session/s-1/subagents/tool-1') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const cancelRes = await request(app) + .post('/session/s-1/subagents/tool-1/cancel') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(resolveRes.status).toBe(200); + expect(resolveRes.headers['cache-control']).toBe('no-store'); + expect(resolveRes.body).toMatchObject({ + taskId: 'agent-1', + status: 'running', + }); + expect(cancelRes.status).toBe(200); + expect(cancelRes.body).toEqual({ cancelled: true }); + expect(resolveSpy).toHaveBeenCalledTimes(2); + expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual(['s-1', 'tool-1']); + expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual(['s-1', 'tool-1']); + expect(bridge.cancelSessionTaskCalls).toEqual([ + { sessionId: 's-1', taskId: 'agent-1', taskKind: 'agent' }, + ]); + } finally { + resolveSpy.mockRestore(); + } + }); + + it('requires the parent runtime for virtual heartbeat and detach', async () => { + const primaryBridge = fakeBridge(); + const secondaryBridge = fakeBridge(); + const registry = createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'ws-primary', + workspaceCwd: WS_BOUND, + primary: true, + bridge: primaryBridge, + }), + makeWorkspaceRuntimeForTest({ + workspaceId: 'ws-secondary', + workspaceCwd: WS_DIFFERENT, + primary: false, + bridge: secondaryBridge, + }), + ]); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { workspaceRegistry: registry }, + ); + const sessionId = createVirtualSubagentSessionId( + 'missing-parent', + 'agent-1', + ); + + const heartbeat = await request(app) + .post(`/session/${sessionId}/heartbeat`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + const detach = await request(app) + .post(`/session/${sessionId}/detach`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(heartbeat.status).toBe(404); + expect(heartbeat.body.sessionId).toBe('missing-parent'); + expect(detach.status).toBe(404); + expect(detach.body.sessionId).toBe('missing-parent'); + }); + + it('serves virtual session stubs without calling the parent bridge', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const sessionId = createVirtualSubagentSessionId('s-1', 'agent-1'); + + const context = await request(app) + .get(`/session/${sessionId}/context`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + const commands = await request(app) + .get(`/session/${sessionId}/supported-commands`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + const heartbeat = await request(app) + .post(`/session/${sessionId}/heartbeat`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('X-Qwen-Client-Id', 'detail-client') + .send({}); + + expect(context.status).toBe(200); + expect(context.body).toEqual({ + v: 1, + sessionId, + workspaceCwd: WS_BOUND, + state: {}, + }); + expect(commands.status).toBe(200); + expect(commands.body).toEqual({ + v: 1, + sessionId, + availableCommands: [], + availableSkills: [], + }); + expect(heartbeat.status).toBe(200); + expect(heartbeat.body).toMatchObject({ + sessionId, + clientId: 'detail-client', + }); + expect(heartbeat.body.lastSeenAt).toEqual(expect.any(Number)); + expect(bridge.sessionContextCalls).toEqual([]); + expect(bridge.sessionSupportedCommandsCalls).toEqual([]); + expect(bridge.heartbeatCalls).toEqual([]); + }); + it('maps task cancellation bridge errors', async () => { const bridge = fakeBridge({ cancelSessionTaskImpl: async (sessionId) => { @@ -8395,6 +8535,29 @@ describe('createServeApp', () => { }); describe('POST /session/:id/load and /resume', () => { + it('reports resume as unsupported for virtual subagent sessions', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const sessionId = createVirtualSubagentSessionId('parent-1', 'agent-1'); + + const res = await request(app) + .post(`/session/${sessionId}/resume`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: 'Virtual subagent sessions do not support resume', + code: 'unsupported_action', + sessionId, + }); + expect(bridge.resumeCalls).toEqual([]); + }); + it('passes the requested initial history page size to load', async () => { const bridge = fakeBridge(); const app = createServeApp( @@ -19820,6 +19983,56 @@ describe('GET /session/:id/events (SSE)', () => { expect(JSON.parse(frames[1]!.data!)).not.toHaveProperty('promptId'); }); + it('streams virtual subagent events without subscribing to the parent session', async () => { + const bridge = fakeBridge({ + subscribeImpl: () => { + throw new Error('parent bridge must not be subscribed'); + }, + }); + const subscribeSpy = vi + .spyOn(VirtualSubagentSessions.prototype, 'subscribe') + .mockResolvedValue( + (async function* () { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { source: 'subagent' }, + } satisfies BridgeEvent; + await new Promise(() => {}); + })(), + ); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + const sessionId = createVirtualSubagentSessionId('parent-1', 'agent-1'); + + try { + const res = await fetch( + `http://127.0.0.1:${port}/session/${sessionId}/events?maxQueued=32`, + { headers: { 'Last-Event-ID': '7' } }, + ); + expect(res.status).toBe(200); + + const frames = await readSseFrames(res.body!, 1); + + expect(JSON.parse(frames[0]!.data!)).toMatchObject({ + id: 1, + data: { source: 'subagent' }, + }); + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(subscribeSpy.mock.calls[0]?.[1]).toBe(sessionId); + expect(subscribeSpy.mock.calls[0]?.[2]).toMatchObject({ + lastEventId: 7, + maxQueued: 32, + }); + } finally { + subscribeSpy.mockRestore(); + } + }); + it('stamps _meta.serverTimestamp on every SSE frame (#4175 F4 prereq, chiga0 #19 P0)', async () => { // The daemon stamps `_meta.serverTimestamp` so multi-client UIs // use the server clock for transcript ordering / "X minutes ago" diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 9151cf9a6e6..1aab9944926 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -139,6 +139,7 @@ import { WorkspaceVoiceCoordinator } from './voice/workspace-voice-coordinator.j import { registerA2uiActionRoutes } from './routes/a2ui-action.js'; import { setRateLimiter } from './rate-limit.js'; import { resolveAcpHttpEnabled } from './acp-http-enabled.js'; +import { VirtualSubagentSessions } from './virtual-subagent-sessions.js'; import { createTotalSessionAdmissionController, type TotalSessionAdmissionSnapshot, @@ -1451,6 +1452,8 @@ export function createServeApp( installAuthProvider: deps.installAuthProvider, }); + const virtualSubagentSessions = new VirtualSubagentSessions(); + registerSessionRoutes(app, { boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, @@ -1462,6 +1465,7 @@ export function createServeApp( promptDeadlineMs: opts.promptDeadlineMs, sessionShellCommandEnabled, languageCodes, + virtualSubagentSessions, }); registerWorkspaceMcpControlRoutes(app, { @@ -1725,6 +1729,7 @@ export function createServeApp( daemonLog, writerIdleTimeoutMs: opts.writerIdleTimeoutMs, sendBridgeError, + virtualSubagentSessions, }); // Official ACP Streamable HTTP transport (RFD #721) mounted at `/acp` diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index 90ebf05cb4e..cdd457c6463 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(48); + expect(registered).toHaveLength(50); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index b5f2c381e07..8e04d6c7c7b 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -794,17 +794,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 48 unique routes with the audited 41/7 attribution split', () => { + it('contains 50 unique routes with the audited 43/7 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(48); - expect(new Set(keys).size).toBe(48); + expect(keys).toHaveLength(50); + expect(new Set(keys).size).toBe(50); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(41); + ).toHaveLength(43); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index a8629b35112..7e5bdc639c8 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -113,6 +113,18 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'GET /session/:id/tasks', }, + { + method: 'GET', + path: '/session/:id/subagents/:toolCallId', + attribution: 'handler_resolved', + route: 'GET /session/:id/subagents/:toolCallId', + }, + { + method: 'POST', + path: '/session/:id/subagents/:toolCallId/cancel', + attribution: 'handler_resolved', + route: 'POST /session/:id/subagents/:toolCallId/cancel', + }, { method: 'GET', path: '/session/:id/lsp', diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts new file mode 100644 index 00000000000..dcff61378a7 --- /dev/null +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -0,0 +1,754 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getSubagentSessionDir, + Storage, + type ChatRecord, +} from '@qwen-code/qwen-code-core'; +import type { WorkspaceRuntime } from './workspace-registry.js'; +import { + createVirtualSubagentSessionId, + parseVirtualSubagentSessionId, + VirtualSubagentSessions, +} from './virtual-subagent-sessions.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +function record( + uuid: string, + parentUuid: string | null, + type: 'user' | 'assistant', + text: string, +): ChatRecord { + return { + uuid, + parentUuid, + sessionId: 'parent-session', + timestamp: new Date().toISOString(), + type, + cwd: '/workspace', + version: 'test', + message: { + role: type === 'assistant' ? 'model' : 'user', + parts: [{ text }], + }, + }; +} + +function activeTarget(sessions: VirtualSubagentSessions): { + refreshLive: () => Promise; + subscribers: number; +} { + const targets = ( + sessions as unknown as { + targets: Map< + string, + { refreshLive: () => Promise; subscribers: number } + >; + } + ).targets; + const target = targets.values().next().value; + if (!target) throw new Error('Expected an active virtual subagent target'); + return target; +} + +describe('VirtualSubagentSessions', () => { + it('rejects id parts that the parser cannot accept', () => { + expect(() => + createVirtualSubagentSessionId('parent session', 'agent-1'), + ).toThrow('valid id parts'); + expect(() => + createVirtualSubagentSessionId('parent-session', 'agent/1'), + ).toThrow('valid id parts'); + }); + + it('resolves, fully loads, and independently streams an agent transcript', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); + tempDirs.push(dir); + const outputFile = path.join(dir, 'agent.jsonl'); + await fs.writeFile( + outputFile, + `${JSON.stringify(record('one', null, 'user', 'task'))}\n${JSON.stringify( + { + ...record('two', 'one', 'assistant', 'first'), + timestamp: new Date(1_000).toISOString(), + agentRunId: 'run-one', + agentRound: 1, + }, + )}\n`, + ); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + runId: 'run-one', + round: 1, + text: 'completed round duplicate', + thought: false, + timestamp: 500, + })}\n${JSON.stringify({ + v: 1, + runId: 'run-one', + round: 2, + text: 'already streaming', + thought: false, + timestamp: Date.now(), + })}\n`, + ); + + const runtime = { + workspaceId: 'workspace-1', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'general-purpose-call-1', + label: 'agent: research', + description: 'research', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + stats: { + totalTokens: 321, + toolUses: 2, + durationMs: 4_500, + }, + outputFile, + isBackgrounded: false, + toolUseId: 'call-1', + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + const sessions = new VirtualSubagentSessions(); + const resolved = await sessions.resolve( + runtime, + 'parent-session', + 'call-1', + ); + + expect(resolved?.title).toBe('agent: research'); + expect(resolved).toMatchObject({ + taskId: 'general-purpose-call-1', + status: 'running', + durationMs: 4_500, + totalTokens: 321, + }); + expect(parseVirtualSubagentSessionId(resolved!.sessionId)).toEqual({ + parentSessionId: 'parent-session', + agentId: 'general-purpose-call-1', + }); + + const loaded = await sessions.load(runtime, resolved!.sessionId, 'detail'); + expect(loaded).toMatchObject({ + sessionId: resolved!.sessionId, + attached: true, + clientId: 'detail', + historyHasMore: false, + }); + expect(loaded?.compactedReplay.length).toBeGreaterThan(0); + expect(JSON.stringify(loaded?.compactedReplay)).toContain( + 'already streaming', + ); + expect(JSON.stringify(loaded?.compactedReplay)).not.toContain( + 'completed round duplicate', + ); + + const abort = new AbortController(); + const stream = await sessions.subscribe(runtime, resolved!.sessionId, { + signal: abort.signal, + lastEventId: loaded?.lastEventId, + }); + const iterator = stream![Symbol.asyncIterator](); + expect((await iterator.next()).value?.type).toBe('replay_complete'); + await fs.rm(`${outputFile}.stream`); + await activeTarget(sessions).refreshLive(); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + runId: 'run-two', + round: 1, + text: `resumed round live ${'x'.repeat(512)}`, + thought: false, + timestamp: Date.now(), + })}\n`, + ); + await activeTarget(sessions).refreshLive(); + const streamed = await iterator.next(); + const reloaded = await sessions.load(runtime, resolved!.sessionId); + await fs.writeFile( + outputFile, + `${JSON.stringify({ + ...record('replacement', null, 'assistant', 'replacement canonical'), + timestamp: new Date(100).toISOString(), + })}\n`, + ); + await activeTarget(sessions).refreshLive(); + const replaced = await iterator.next(); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + text: 'stream after rewind', + thought: false, + timestamp: 200, + })}\n`, + ); + await activeTarget(sessions).refreshLive(); + const afterRewind = await iterator.next(); + abort.abort(); + await iterator.return?.(); + expect(streamed?.value).toMatchObject({ type: 'session_update' }); + expect(JSON.stringify(reloaded?.compactedReplay)).toContain( + 'resumed round live', + ); + expect(JSON.stringify(replaced?.value)).toContain('replacement canonical'); + expect(JSON.stringify(afterRewind.value)).toContain('stream after rewind'); + }); + + it('releases the subscriber count when the initial refresh fails', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); + tempDirs.push(dir); + const outputFile = path.join(dir, 'not-a-file'); + await fs.mkdir(outputFile); + const runtime = { + workspaceId: 'workspace-refresh-error', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'agent-error', + label: 'agent', + description: 'agent', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile, + isBackgrounded: false, + toolUseId: 'call-error', + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + const sessions = new VirtualSubagentSessions(); + const sessionId = createVirtualSubagentSessionId( + 'parent-session', + 'agent-error', + ); + const stream = await sessions.subscribe(runtime, sessionId, { + signal: new AbortController().signal, + }); + + await expect(stream![Symbol.asyncIterator]().next()).rejects.toThrow(); + expect(activeTarget(sessions).subscribers).toBe(0); + }); + + it('isolates cached targets by workspace runtime', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); + tempDirs.push(dir); + const makeRuntime = async (workspaceId: string, text: string) => { + const outputFile = path.join(dir, `${workspaceId}.jsonl`); + await fs.writeFile( + outputFile, + `${JSON.stringify(record('one', null, 'user', text))}\n`, + ); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + round: 1, + text: `${text} stale stream`, + thought: false, + timestamp: Date.now(), + })}\n`, + ); + return { + workspaceId, + workspaceCwd: `/workspace/${workspaceId}`, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'agent-1', + label: 'agent', + description: 'agent', + status: 'completed' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile, + isBackgrounded: false, + toolUseId: 'call-1', + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + }; + const firstRuntime = await makeRuntime('workspace-1', 'first workspace'); + const secondRuntime = await makeRuntime('workspace-2', 'second workspace'); + const sessionId = 'subagent.cGFyZW50LXNlc3Npb24.YWdlbnQtMQ'; + const sessions = new VirtualSubagentSessions(); + + const first = await sessions.load(firstRuntime, sessionId); + const second = await sessions.load(secondRuntime, sessionId); + + expect(first?.workspaceCwd).toBe('/workspace/workspace-1'); + expect(second?.workspaceCwd).toBe('/workspace/workspace-2'); + expect(JSON.stringify(second?.compactedReplay)).toContain( + 'second workspace', + ); + expect(JSON.stringify(second?.compactedReplay)).not.toContain( + 'first workspace', + ); + expect(JSON.stringify(second?.compactedReplay)).not.toContain( + 'stale stream', + ); + }); + + it('keeps later canonical rounds when one streamed round is reconciled', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); + tempDirs.push(dir); + const outputFile = path.join(dir, 'agent.jsonl'); + await fs.writeFile( + outputFile, + `${JSON.stringify(record('one', null, 'user', 'task'))}\n`, + ); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + runId: 'run-batch', + round: 1, + text: 'streamed first round', + thought: false, + timestamp: Date.now(), + })}\n`, + ); + const runtime = { + workspaceId: 'workspace-batch', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'agent-batch', + label: 'agent', + description: 'agent', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile, + isBackgrounded: false, + toolUseId: 'call-batch', + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + const sessions = new VirtualSubagentSessions(); + const resolved = await sessions.resolve( + runtime, + 'parent-session', + 'call-batch', + ); + const loaded = await sessions.load(runtime, resolved!.sessionId); + const abort = new AbortController(); + const stream = await sessions.subscribe(runtime, resolved!.sessionId, { + signal: abort.signal, + lastEventId: loaded!.lastEventId, + }); + const iterator = stream![Symbol.asyncIterator](); + expect((await iterator.next()).value?.type).toBe('replay_complete'); + + await fs.appendFile( + outputFile, + `${JSON.stringify({ + ...record('two', 'one', 'assistant', 'streamed first round'), + agentRunId: 'run-batch', + agentRound: 1, + })}\n${JSON.stringify({ + ...record('three', 'two', 'assistant', 'canonical second round'), + agentRunId: 'run-batch', + agentRound: 2, + })}\n`, + ); + + const update = await iterator.next(); + abort.abort(); + await iterator.return?.(); + expect(JSON.stringify(update.value)).toContain('canonical second round'); + }); + + it('does not replay a second load snapshot again on subscribe', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); + tempDirs.push(dir); + const outputFile = path.join(dir, 'agent.jsonl'); + await fs.writeFile( + outputFile, + `${JSON.stringify(record('one', null, 'user', 'task'))}\n`, + ); + const runtime = { + workspaceId: 'workspace-reload', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'agent-reload', + label: 'agent', + description: 'agent', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile, + isBackgrounded: false, + toolUseId: 'call-reload', + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + const sessions = new VirtualSubagentSessions(); + const resolved = await sessions.resolve( + runtime, + 'parent-session', + 'call-reload', + ); + await sessions.load(runtime, resolved!.sessionId); + await fs.appendFile( + outputFile, + `${JSON.stringify({ + ...record('two', 'one', 'assistant', 'between snapshots'), + agentRunId: 'run-reload', + agentRound: 1, + })}\n`, + ); + + const loaded = await sessions.load(runtime, resolved!.sessionId); + expect(JSON.stringify(loaded!.compactedReplay)).toContain( + 'between snapshots', + ); + await fs.writeFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + runId: 'run-reload', + round: 1, + text: 'between snapshots', + thought: false, + timestamp: Date.now(), + })}\n`, + ); + const abort = new AbortController(); + const stream = await sessions.subscribe(runtime, resolved!.sessionId, { + signal: abort.signal, + lastEventId: loaded!.lastEventId, + }); + const iterator = stream![Symbol.asyncIterator](); + const replayed: unknown[] = []; + for (;;) { + const next = await iterator.next(); + if (next.value?.type === 'replay_complete') break; + replayed.push(next.value); + } + abort.abort(); + await iterator.return?.(); + expect(JSON.stringify(replayed)).not.toContain('between snapshots'); + }); + + it('keeps task status while supplementing terminal metrics', async () => { + const runtimeDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-subagent-runtime-'), + ); + tempDirs.push(runtimeDir); + const workspaceCwd = path.join(runtimeDir, 'workspace'); + const projectDir = Storage.runWithRuntimeBaseDir( + runtimeDir, + workspaceCwd, + () => new Storage(workspaceCwd).getProjectDir(), + ); + const parentSessionId = 'running-parent'; + const toolCallId = 'call-running'; + const outputFile = path.join(runtimeDir, 'running-agent.jsonl'); + await fs.mkdir(path.join(projectDir, 'chats'), { recursive: true }); + await fs.writeFile( + path.join(projectDir, 'chats', `${parentSessionId}.jsonl`), + `${JSON.stringify({ + ...record('result', null, 'user', ''), + sessionId: parentSessionId, + type: 'tool_result', + toolCallResult: { + callId: toolCallId, + status: 'success', + resultDisplay: { + type: 'task_execution', + status: 'running', + executionSummary: { totalTokens: 999 }, + }, + }, + })}\n`, + ); + await fs.writeFile( + outputFile, + `${JSON.stringify(record('child', null, 'user', 'task'))}\n`, + ); + let taskStatus: 'running' | 'completed' = 'running'; + const runtime = { + workspaceId: 'running-workspace', + workspaceCwd, + env: { + mode: 'runtime-overlay', + overlayKeys: ['QWEN_RUNTIME_DIR'], + effectiveEnv: { QWEN_RUNTIME_DIR: runtimeDir }, + }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: parentSessionId, + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'general-purpose-running', + label: 'running agent', + description: 'running agent', + status: taskStatus, + startTime: Date.now(), + runtimeMs: 1, + stats: { totalTokens: 123, toolUses: 1, durationMs: 500 }, + outputFile, + isBackgrounded: false, + toolUseId: toolCallId, + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + + const sessions = new VirtualSubagentSessions(); + const resolved = await sessions.resolve( + runtime, + parentSessionId, + toolCallId, + ); + + expect(resolved).toMatchObject({ + status: 'running', + durationMs: 500, + totalTokens: 123, + }); + const loaded = await sessions.load(runtime, resolved!.sessionId); + const abort = new AbortController(); + const stream = await sessions.subscribe(runtime, resolved!.sessionId, { + signal: abort.signal, + lastEventId: loaded!.lastEventId, + }); + const iterator = stream![Symbol.asyncIterator](); + expect((await iterator.next()).value?.type).toBe('replay_complete'); + + taskStatus = 'completed'; + await fs.appendFile( + outputFile, + `${JSON.stringify( + record('final', 'child', 'assistant', 'final canonical output'), + )}\n`, + ); + expect( + await sessions.resolve(runtime, parentSessionId, toolCallId), + ).toMatchObject({ status: 'completed', totalTokens: 999 }); + await activeTarget(sessions).refreshLive(); + const finalUpdate = await iterator.next(); + expect(JSON.stringify(finalUpdate?.value)).toContain( + 'final canonical output', + ); + + await fs.appendFile( + `${outputFile}.stream`, + `${JSON.stringify({ + v: 1, + round: 1, + text: 'must not be polled after completion', + thought: false, + timestamp: Date.now(), + })}\n`, + ); + const next = iterator.next(); + let settled = false; + void next.then(() => { + settled = true; + }); + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(1_000); + expect(settled).toBe(false); + vi.useRealTimers(); + abort.abort(); + await next; + await iterator.return?.(); + }); + + it('matches legacy sidecars through the recorded launch prompt', async () => { + const runtimeDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-subagent-runtime-'), + ); + tempDirs.push(runtimeDir); + const workspaceCwd = path.join(runtimeDir, 'workspace'); + const projectDir = Storage.runWithRuntimeBaseDir( + runtimeDir, + workspaceCwd, + () => new Storage(workspaceCwd).getProjectDir(), + ); + const parentSessionId = 'legacy-parent'; + const toolCallId = 'call-old'; + const prompt = 'legacy launch prompt'; + await fs.mkdir(path.join(projectDir, 'chats'), { recursive: true }); + await fs.writeFile( + path.join(projectDir, 'chats', `${parentSessionId}.jsonl`), + `${JSON.stringify({ + ...record('root', null, 'assistant', ''), + sessionId: parentSessionId, + message: { + role: 'model', + parts: [ + { + functionCall: { + id: toolCallId, + name: 'agent', + args: { + description: 'legacy task', + prompt, + subagent_type: 'general-purpose', + }, + }, + }, + ], + }, + })}\n${JSON.stringify({ + ...record('result', 'root', 'user', ''), + sessionId: parentSessionId, + type: 'tool_result', + toolCallResult: { + callId: toolCallId, + status: 'success', + resultDisplay: { + type: 'task_execution', + status: 'completed', + tokenCount: 123, + executionSummary: { + totalDurationMs: 88_610, + totalTokens: 1_234, + inputTokens: 1_000, + outputTokens: 234, + cachedTokens: 800, + }, + }, + }, + })}\n`, + ); + const sessionDir = getSubagentSessionDir(projectDir, parentSessionId); + await fs.mkdir(sessionDir, { recursive: true }); + const outputFile = path.join( + sessionDir, + 'agent-general-purpose-random.jsonl', + ); + await fs.writeFile( + outputFile, + `${JSON.stringify({ + ...record('child', null, 'user', prompt), + sessionId: parentSessionId, + })}\n`, + ); + await fs.writeFile( + outputFile.replace(/\.jsonl$/, '.meta.json'), + JSON.stringify({ + agentId: 'general-purpose-random', + agentType: 'general-purpose', + description: 'legacy task', + parentSessionId, + parentAgentId: null, + createdAt: new Date().toISOString(), + status: 'completed', + }), + ); + const runtime = { + workspaceId: 'legacy-workspace', + workspaceCwd, + env: { + mode: 'runtime-overlay', + overlayKeys: ['QWEN_RUNTIME_DIR'], + effectiveEnv: { QWEN_RUNTIME_DIR: runtimeDir }, + }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: parentSessionId, + now: Date.now(), + tasks: [], + }), + }, + } as unknown as WorkspaceRuntime; + + const resolved = await new VirtualSubagentSessions().resolve( + runtime, + parentSessionId, + toolCallId, + ); + + expect(parseVirtualSubagentSessionId(resolved!.sessionId)?.agentId).toBe( + 'general-purpose-random', + ); + expect(resolved).toMatchObject({ + status: 'completed', + durationMs: 88_610, + totalTokens: 1_234, + inputTokens: 1_000, + outputTokens: 234, + cachedTokens: 800, + }); + }); +}); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts new file mode 100644 index 00000000000..d29b20e0f1a --- /dev/null +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -0,0 +1,961 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import readline from 'node:readline'; +import { + getSubagentSessionDir, + parseLineTolerant, + read as readJsonl, + readAgentMeta, + Storage, + type ChatRecord, + type SessionTranscriptCursorState, + type SessionTranscriptRecordPage, +} from '@qwen-code/qwen-code-core'; +import { EventBus, type BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { createTranscriptMessageUpdate } from '@qwen-code/acp-bridge/transcriptReplay'; +import { replayTranscriptRecordPage } from '../acp-integration/session/history-replay-page.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +const PREFIX = 'subagent.'; +const POLL_INTERVAL_MS = 250; +const TARGET_RETENTION_MS = 60_000; + +interface VirtualSubagentSessionKey { + parentSessionId: string; + agentId: string; +} + +interface ResolvedAgentTask { + id: string; + title: string; + outputFile: string; + status: string; + startTime: number; + durationMs?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; +} + +interface AgentStreamRecord { + v: 1; + runId?: string; + round?: number; + text: string; + thought: boolean; + timestamp: number; +} + +interface TranscriptReadBounds { + transcript: number; + stream: number; +} + +export interface ResolvedVirtualSubagentSession { + sessionId: string; + taskId: string; + title: string; + status: string; + durationMs?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; +} + +interface ToolCallMetrics { + status?: string; + durationMs?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; +} + +async function readFirstUserText( + filePath: string, +): Promise { + const stream = createReadStream(filePath); + const lines = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + try { + for await (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + for (const record of parseLineTolerant(trimmed, filePath)) { + if (record.type !== 'user') continue; + return record.message?.parts?.find( + (part) => typeof part.text === 'string', + )?.text; + } + } + return undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } finally { + lines.close(); + stream.destroy(); + } +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; +} + +function finiteNonNegative(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + +function normalizeTaskStatus(status: unknown): string | undefined { + if (typeof status !== 'string') return undefined; + if (status === 'success') return 'completed'; + if (status === 'error') return 'failed'; + if (status === 'background') return 'running'; + return status; +} + +function durationBetween(start: string, end?: string): number | undefined { + if (!end) return undefined; + const startTime = Date.parse(start); + const endTime = Date.parse(end); + return Number.isFinite(startTime) && Number.isFinite(endTime) + ? Math.max(0, endTime - startTime) + : undefined; +} + +function findToolCallMetrics( + records: readonly ChatRecord[], + toolCallId: string, +): ToolCallMetrics { + const toolResult = records.find( + (record) => record.toolCallResult?.callId === toolCallId, + )?.toolCallResult; + const display = asRecord(toolResult?.resultDisplay); + const summary = asRecord(display?.['executionSummary']); + return { + status: + normalizeTaskStatus(display?.['status']) ?? + normalizeTaskStatus(toolResult?.status), + durationMs: finiteNonNegative(summary?.['totalDurationMs']), + totalTokens: + finiteNonNegative(summary?.['totalTokens']) ?? + finiteNonNegative(display?.['tokenCount']), + inputTokens: finiteNonNegative(summary?.['inputTokens']), + outputTokens: finiteNonNegative(summary?.['outputTokens']), + cachedTokens: finiteNonNegative(summary?.['cachedTokens']), + }; +} + +function encodePart(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function decodePart(value: string): string | undefined { + try { + return Buffer.from(value, 'base64url').toString('utf8'); + } catch { + return undefined; + } +} + +function isValidVirtualSessionPart(value: string): boolean { + return /^[a-zA-Z0-9_-]{1,500}$/.test(value); +} + +export function createVirtualSubagentSessionId( + parentSessionId: string, + agentId: string, +): string { + if ( + !isValidVirtualSessionPart(parentSessionId) || + !isValidVirtualSessionPart(agentId) + ) { + throw new Error('Virtual subagent session ids require valid id parts'); + } + return `${PREFIX}${encodePart(parentSessionId)}.${encodePart(agentId)}`; +} + +export function parseVirtualSubagentSessionId( + sessionId: string, +): VirtualSubagentSessionKey | undefined { + if (!sessionId.startsWith(PREFIX) || sessionId.length > 2_000) { + return undefined; + } + const parts = sessionId.slice(PREFIX.length).split('.'); + if (parts.length !== 2) return undefined; + const parentSessionId = decodePart(parts[0]!); + const agentId = decodePart(parts[1]!); + if ( + !parentSessionId || + !agentId || + !isValidVirtualSessionPart(parentSessionId) || + !isValidVirtualSessionPart(agentId) + ) { + return undefined; + } + return { parentSessionId, agentId }; +} + +function replayCursorState( + sessionId: string, + position: number, + leafUuid: string, + startTime: string, + lastUpdated: string, +): SessionTranscriptCursorState { + return { + v: 1, + sessionId, + fileIdentity: { dev: 0, ino: 0 }, + snapshotSize: position, + position, + leafUuid, + startTime, + lastUpdated, + }; +} + +class VirtualSubagentTarget { + private readonly bus = new EventBus(1_024, 8); + private readonly events: BridgeEvent[] = []; + private snapshotDelivered = false; + private offset = 0; + private transcriptIdentity: string | undefined; + private streamOffset = 0; + private streamIdentity: string | undefined; + private streamReady = false; + private canonicalThroughTimestamp = 0; + private readonly completedStreamRounds = new Set(); + private readonly streamedRounds = new Set(); + private readonly streamRunIds = new Set(); + private legacyStreamedSinceCanonical = false; + private replayState: unknown; + private initialized = false; + private refreshPromise: Promise = Promise.resolve(); + private snapshotPromise: Promise = Promise.resolve(); + private pollTimer: NodeJS.Timeout | undefined; + private subscribers = 0; + private retentionTimer: NodeJS.Timeout | undefined; + + constructor( + readonly sessionId: string, + readonly parentSessionId: string, + readonly task: ResolvedAgentTask, + private readonly workspaceCwd: string, + private readonly onExpired: () => void, + ) {} + + updateStatus(status: string): void { + const wasRunning = this.task.status === 'running'; + this.task.status = status; + if (status !== 'running' && this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + if (wasRunning && status !== 'running') { + void this.refreshLive().catch(() => undefined); + } + } + + private rememberEvent(event: BridgeEvent | undefined): void { + if (event && !this.snapshotDelivered) this.events.push(event); + } + + private resetCanonicalState(): void { + this.offset = 0; + this.replayState = undefined; + this.canonicalThroughTimestamp = 0; + this.completedStreamRounds.clear(); + this.streamRunIds.clear(); + this.streamedRounds.clear(); + this.legacyStreamedSinceCanonical = false; + } + + private resetStreamState(): void { + this.streamOffset = 0; + this.completedStreamRounds.clear(); + this.streamRunIds.clear(); + this.streamedRounds.clear(); + this.legacyStreamedSinceCanonical = false; + } + + private async readNewRecords(endOffset?: number): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(this.task.outputFile, 'r'); + const stat = await handle.stat(); + const identity = `${stat.dev}:${stat.ino}`; + if ( + (this.transcriptIdentity !== undefined && + this.transcriptIdentity !== identity) || + stat.size < this.offset + ) { + this.resetCanonicalState(); + } + this.transcriptIdentity = identity; + const size = Math.min(stat.size, endOffset ?? stat.size); + if (size <= this.offset) return []; + const bytes = Buffer.alloc(size - this.offset); + const { bytesRead } = await handle.read( + bytes, + 0, + bytes.length, + this.offset, + ); + const chunk = bytes.subarray(0, bytesRead); + const lastNewline = chunk.lastIndexOf(0x0a); + if (lastNewline < 0) return []; + const complete = chunk.subarray(0, lastNewline + 1); + this.offset += complete.length; + return complete + .toString('utf8') + .split('\n') + .flatMap((line) => { + const trimmed = line.trim(); + return trimmed + ? parseLineTolerant(trimmed, this.task.outputFile) + : []; + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (this.transcriptIdentity !== undefined) { + this.transcriptIdentity = undefined; + this.resetCanonicalState(); + } + return []; + } + throw error; + } finally { + await handle?.close(); + } + } + + private async readStreamUpdates(endOffset?: number): Promise { + const replayingExisting = !this.streamReady; + this.streamReady = true; + const filePath = `${this.task.outputFile}.stream`; + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(filePath, 'r'); + const stat = await handle.stat(); + const identity = `${stat.dev}:${stat.ino}`; + if ( + this.streamIdentity !== undefined && + this.streamIdentity !== identity + ) { + this.resetStreamState(); + } + this.streamIdentity = identity; + if (stat.size < this.streamOffset) this.resetStreamState(); + const size = Math.min(stat.size, endOffset ?? stat.size); + if (size <= this.streamOffset) return; + const bytes = Buffer.alloc(size - this.streamOffset); + const { bytesRead } = await handle.read( + bytes, + 0, + bytes.length, + this.streamOffset, + ); + const chunk = bytes.subarray(0, bytesRead); + const lastNewline = chunk.lastIndexOf(0x0a); + if (lastNewline < 0) return; + const complete = chunk.subarray(0, lastNewline + 1); + this.streamOffset += complete.length; + const records = complete + .toString('utf8') + .split('\n') + .flatMap((line) => { + const trimmed = line.trim(); + return trimmed + ? parseLineTolerant(trimmed, filePath) + : []; + }); + for (const record of records) { + if ( + record.v !== 1 || + typeof record.text !== 'string' || + typeof record.timestamp !== 'number' + ) { + continue; + } + const roundKey = + typeof record.runId === 'string' && typeof record.round === 'number' + ? `${record.runId}:${record.round}` + : undefined; + if (typeof record.runId === 'string') { + this.streamRunIds.add(record.runId); + } + if ( + (roundKey + ? this.completedStreamRounds.has(roundKey) + : record.timestamp <= this.canonicalThroughTimestamp) || + (replayingExisting && typeof record.round !== 'number') + ) { + continue; + } + const event = this.bus.publish({ + type: 'session_update', + data: createTranscriptMessageUpdate({ + role: 'assistant', + text: record.text, + timestamp: record.timestamp, + ...(record.thought ? { thought: true } : {}), + }), + }); + this.rememberEvent(event); + if (roundKey) this.streamedRounds.add(roundKey); + else this.legacyStreamedSinceCanonical = true; + } + for (const completed of this.completedStreamRounds) { + const runId = completed.slice(0, completed.lastIndexOf(':')); + if (!this.streamRunIds.has(runId)) { + this.completedStreamRounds.delete(completed); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + this.streamIdentity = undefined; + this.streamOffset = 0; + this.streamRunIds.clear(); + this.streamedRounds.clear(); + this.legacyStreamedSinceCanonical = false; + } finally { + await handle?.close(); + } + } + + private refreshOnce = async (endOffset?: number): Promise => { + let records = await this.readNewRecords(endOffset); + if (records.length === 0) { + this.initialized = true; + return; + } + for (const record of records) { + if ( + record.type === 'assistant' && + (record.usageMetadata !== undefined || + record.message?.parts?.some((part) => typeof part.text === 'string')) + ) { + if ( + typeof record.agentRunId === 'string' && + typeof record.agentRound === 'number' + ) { + this.completedStreamRounds.add( + `${record.agentRunId}:${record.agentRound}`, + ); + } + const timestamp = Date.parse(record.timestamp); + if (Number.isFinite(timestamp)) { + this.canonicalThroughTimestamp = Math.max( + this.canonicalThroughTimestamp, + timestamp, + ); + } + } + } + if (this.streamedRounds.size > 0 || this.legacyStreamedSinceCanonical) { + records = records.filter((record) => { + const roundKey = + typeof record.agentRunId === 'string' && + typeof record.agentRound === 'number' + ? `${record.agentRunId}:${record.agentRound}` + : undefined; + if (roundKey && this.streamedRounds.delete(roundKey)) return false; + if ( + !this.legacyStreamedSinceCanonical || + record.type !== 'assistant' || + record.message?.parts?.some((part) => part.functionCall) + ) { + return true; + } + this.legacyStreamedSinceCanonical = false; + return false; + }); + if (records.length === 0) { + this.initialized = true; + return; + } + } + const startTime = records[0]?.timestamp ?? new Date().toISOString(); + const lastUpdated = + records[records.length - 1]?.timestamp ?? new Date().toISOString(); + const page: SessionTranscriptRecordPage = { + sessionId: this.sessionId, + filePath: this.task.outputFile, + records, + gaps: [], + hasMore: true, + replay: this.replayState, + startTime, + lastUpdated, + nextCursorState: replayCursorState( + this.sessionId, + this.offset, + records[records.length - 1]?.uuid ?? '', + startTime, + lastUpdated, + ), + }; + let nextReplayState: unknown; + const replay = await replayTranscriptRecordPage({ + sessionId: this.sessionId, + page, + encodeCursor: (state) => { + nextReplayState = state.replay; + return 'virtual-subagent-replay'; + }, + }); + this.replayState = nextReplayState; + const inputs = replay.updates.map((update) => ({ + type: 'session_update', + data: update, + })); + const published = this.initialized + ? inputs.flatMap((input) => { + const event = this.bus.publish(input); + return event ? [event] : []; + }) + : this.bus.seedReplayEvents(inputs); + if (!this.snapshotDelivered) this.events.push(...published); + this.initialized = true; + }; + + private refreshAt(bounds?: TranscriptReadBounds): Promise { + return this.refreshOnce(bounds?.transcript).then(async () => { + if (this.task.status === 'running') { + await this.readStreamUpdates(bounds?.stream); + } + }); + } + + private enqueueRefresh(work: () => Promise): Promise { + const result = this.refreshPromise.catch(() => undefined).then(work); + this.refreshPromise = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + refreshLive(): Promise { + return this.enqueueRefresh(() => this.refreshAt()); + } + + private async captureReadBounds(): Promise { + const size = async (filePath: string): Promise => { + try { + return (await fs.stat(filePath)).size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + }; + const [transcript, stream] = await Promise.all([ + size(this.task.outputFile), + size(`${this.task.outputFile}.stream`), + ]); + return { transcript, stream }; + } + + private async createSnapshotOnce(): Promise<{ + events: BridgeEvent[]; + lastEventId: number; + }> { + if (!this.snapshotDelivered) { + await this.refreshLive(); + const snapshot = [...this.events]; + this.events.length = 0; + this.snapshotDelivered = true; + return { events: snapshot, lastEventId: this.bus.lastEventId }; + } + return this.enqueueRefresh(async () => { + const bounds = await this.captureReadBounds(); + const target = new VirtualSubagentTarget( + this.sessionId, + this.parentSessionId, + this.task, + this.workspaceCwd, + () => undefined, + ); + await target.refreshAt(bounds); + await this.refreshAt(bounds); + return { + events: [...target.events], + lastEventId: this.bus.lastEventId, + }; + }); + } + + private createSnapshot(): Promise<{ + events: BridgeEvent[]; + lastEventId: number; + }> { + const snapshot = this.snapshotPromise.then(() => this.createSnapshotOnce()); + this.snapshotPromise = snapshot.then( + () => undefined, + () => undefined, + ); + return snapshot; + } + + async load(clientId?: string) { + const snapshot = await this.createSnapshot(); + if (this.subscribers === 0) this.scheduleRetention(); + return { + sessionId: this.sessionId, + workspaceCwd: this.workspaceCwd, + attached: true, + ...(clientId ? { clientId } : {}), + createdAt: new Date(this.task.startTime).toISOString(), + hasActivePrompt: this.task.status === 'running', + state: {}, + compactedReplay: snapshot.events, + liveJournal: [], + historyHasMore: false, + lastEventId: snapshot.lastEventId, + }; + } + + private async *iterate(opts: { + signal: AbortSignal; + lastEventId?: number; + maxQueued?: number; + }): AsyncIterableIterator { + if (this.retentionTimer) { + clearTimeout(this.retentionTimer); + this.retentionTimer = undefined; + } + this.subscribers++; + try { + await this.refreshLive(); + if (!this.snapshotDelivered) { + this.events.length = 0; + this.snapshotDelivered = true; + } + if (this.task.status === 'running' && !this.pollTimer) { + this.pollTimer = setInterval(() => { + void this.refreshLive().catch(() => undefined); + }, POLL_INTERVAL_MS); + this.pollTimer.unref(); + } + yield* this.bus.subscribe(opts); + } finally { + this.subscribers--; + if (this.subscribers === 0) { + if (this.pollTimer) clearInterval(this.pollTimer); + this.pollTimer = undefined; + this.scheduleRetention(); + } + } + } + + subscribe(opts: { + signal: AbortSignal; + lastEventId?: number; + maxQueued?: number; + }): AsyncIterable { + return { + [Symbol.asyncIterator]: () => this.iterate(opts), + }; + } + + private scheduleRetention(): void { + if (this.retentionTimer) clearTimeout(this.retentionTimer); + this.retentionTimer = setTimeout(this.onExpired, TARGET_RETENTION_MS); + this.retentionTimer.unref(); + } +} + +export class VirtualSubagentSessions { + private readonly targets = new Map(); + + private async findTask( + runtime: WorkspaceRuntime, + parentSessionId: string, + predicate: (task: { + kind: string; + id: string; + outputFile?: string; + toolUseId?: string; + }) => boolean, + ): Promise { + const status = await runtime.bridge.getSessionTasksStatus(parentSessionId); + const task = status.tasks.find( + (candidate) => + candidate.kind === 'agent' && + typeof candidate.outputFile === 'string' && + predicate(candidate), + ); + if (task?.kind === 'agent' && task.outputFile) { + return { + id: task.id, + title: task.label, + outputFile: task.outputFile, + status: task.status, + startTime: task.startTime, + durationMs: + task.stats?.durationMs ?? + (task.endTime === undefined + ? undefined + : Math.max(0, task.endTime - task.startTime)), + totalTokens: task.stats?.totalTokens, + }; + } + + const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; + const projectDir = Storage.runWithRuntimeBaseDir( + runtimeDir, + runtime.workspaceCwd, + () => new Storage(runtime.workspaceCwd).getProjectDir(), + ); + const sessionDir = getSubagentSessionDir(projectDir, parentSessionId); + let names: string[]; + try { + names = await fs.readdir(sessionDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + for (const name of names) { + if (!name.endsWith('.meta.json')) continue; + const metaPath = `${sessionDir}/${name}`; + const meta = readAgentMeta(metaPath); + if ( + !meta || + !predicate({ + kind: 'agent', + id: meta.agentId, + toolUseId: meta.toolUseId, + outputFile: metaPath.slice(0, -'.meta.json'.length) + '.jsonl', + }) + ) { + continue; + } + return { + id: meta.agentId, + title: meta.description || meta.agentType, + outputFile: metaPath.slice(0, -'.meta.json'.length) + '.jsonl', + status: meta.status ?? 'completed', + startTime: Number.isFinite(Date.parse(meta.createdAt)) + ? Date.parse(meta.createdAt) + : Date.now(), + durationMs: durationBetween(meta.createdAt, meta.lastUpdatedAt), + }; + } + return undefined; + } + + private async findLegacyTaskByToolCall( + runtime: WorkspaceRuntime, + parentSessionId: string, + toolCallId: string, + ): Promise { + // Pre-toolUseId transcripts cannot be linked exactly. This score is only a + // best-effort compatibility path and identical parallel launches may tie. + const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; + const projectDir = Storage.runWithRuntimeBaseDir( + runtimeDir, + runtime.workspaceCwd, + () => new Storage(runtime.workspaceCwd).getProjectDir(), + ); + const parentRecords = await readJsonl( + `${projectDir}/chats/${parentSessionId}.jsonl`, + ); + let root: + | { + timestamp: number; + description?: string; + prompt?: string; + agentType?: string; + } + | undefined; + for (const record of parentRecords) { + for (const part of record.message?.parts ?? []) { + const call = part.functionCall; + if (call?.id !== toolCallId || call.name !== 'agent') continue; + const args = call.args; + root = { + timestamp: Date.parse(record.timestamp), + ...(typeof args?.['description'] === 'string' + ? { description: args['description'] } + : {}), + ...(typeof args?.['prompt'] === 'string' + ? { prompt: args['prompt'] } + : {}), + ...(typeof args?.['subagent_type'] === 'string' + ? { agentType: args['subagent_type'] } + : {}), + }; + break; + } + if (root) break; + } + if (!root) return undefined; + + const sessionDir = getSubagentSessionDir(projectDir, parentSessionId); + let names: string[]; + try { + names = await fs.readdir(sessionDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + const candidates: Array< + ResolvedAgentTask & { score: number; delta: number } + > = []; + for (const name of names) { + if (!name.endsWith('.meta.json')) continue; + const metaPath = `${sessionDir}/${name}`; + const meta = readAgentMeta(metaPath); + if (!meta) continue; + const outputFile = metaPath.slice(0, -'.meta.json'.length) + '.jsonl'; + const launchPrompt = await readFirstUserText(outputFile); + let score = 0; + if (root.prompt && launchPrompt === root.prompt) score += 8; + if (root.description && meta.description === root.description) score += 4; + if (root.agentType && meta.agentType === root.agentType) score += 2; + const startTime = Date.parse(meta.createdAt); + const delta = Math.abs(startTime - root.timestamp); + if (Number.isFinite(delta) && delta <= 60_000) score += 1; + if (score === 0) continue; + candidates.push({ + id: meta.agentId, + title: meta.description || meta.agentType, + outputFile, + status: meta.status ?? 'completed', + startTime: Number.isFinite(startTime) ? startTime : Date.now(), + durationMs: durationBetween(meta.createdAt, meta.lastUpdatedAt), + score, + delta, + }); + } + candidates.sort((a, b) => b.score - a.score || a.delta - b.delta); + const selected = candidates[0]; + if (!selected) return undefined; + const metrics = findToolCallMetrics(parentRecords, toolCallId); + return { + ...selected, + status: metrics.status ?? selected.status, + durationMs: metrics.durationMs ?? selected.durationMs, + totalTokens: metrics.totalTokens ?? selected.totalTokens, + inputTokens: metrics.inputTokens ?? selected.inputTokens, + outputTokens: metrics.outputTokens ?? selected.outputTokens, + cachedTokens: metrics.cachedTokens ?? selected.cachedTokens, + }; + } + + private async readParentToolCallMetrics( + runtime: WorkspaceRuntime, + parentSessionId: string, + toolCallId: string, + ): Promise { + const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; + const projectDir = Storage.runWithRuntimeBaseDir( + runtimeDir, + runtime.workspaceCwd, + () => new Storage(runtime.workspaceCwd).getProjectDir(), + ); + const records = await readJsonl( + `${projectDir}/chats/${parentSessionId}.jsonl`, + ); + return findToolCallMetrics(records, toolCallId); + } + + async resolve( + runtime: WorkspaceRuntime, + parentSessionId: string, + toolCallId: string, + ): Promise { + let task = await this.findTask( + runtime, + parentSessionId, + (candidate) => + candidate.toolUseId === toolCallId || + candidate.id.endsWith(`-${toolCallId}`), + ); + const metrics = + task && task.status !== 'running' + ? await this.readParentToolCallMetrics( + runtime, + parentSessionId, + toolCallId, + ) + : undefined; + task ??= await this.findLegacyTaskByToolCall( + runtime, + parentSessionId, + toolCallId, + ); + if (!task) return undefined; + const sessionId = createVirtualSubagentSessionId(parentSessionId, task.id); + const status = task.status; + this.targets + .get(`${runtime.workspaceId}:${sessionId}`) + ?.updateStatus(status); + return { + sessionId, + taskId: task.id, + title: task.title, + status, + durationMs: metrics?.durationMs ?? task.durationMs, + totalTokens: metrics?.totalTokens ?? task.totalTokens, + inputTokens: metrics?.inputTokens ?? task.inputTokens, + outputTokens: metrics?.outputTokens ?? task.outputTokens, + cachedTokens: metrics?.cachedTokens ?? task.cachedTokens, + }; + } + + private async getTarget( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise { + const targetKey = `${runtime.workspaceId}:${sessionId}`; + const cached = this.targets.get(targetKey); + if (cached) return cached; + const key = parseVirtualSubagentSessionId(sessionId); + if (!key) return undefined; + const task = await this.findTask( + runtime, + key.parentSessionId, + (candidate) => candidate.id === key.agentId, + ); + if (!task) return undefined; + const existing = this.targets.get(targetKey); + if (existing) return existing; + const target = new VirtualSubagentTarget( + sessionId, + key.parentSessionId, + task, + runtime.workspaceCwd, + () => this.targets.delete(targetKey), + ); + this.targets.set(targetKey, target); + return target; + } + + async load(runtime: WorkspaceRuntime, sessionId: string, clientId?: string) { + return (await this.getTarget(runtime, sessionId))?.load(clientId); + } + + async subscribe( + runtime: WorkspaceRuntime, + sessionId: string, + opts: { signal: AbortSignal; lastEventId?: number; maxQueued?: number }, + ): Promise | undefined> { + return (await this.getTarget(runtime, sessionId))?.subscribe(opts); + } +} diff --git a/packages/core/src/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index c4554587a6f..e80ce6636a8 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -296,6 +296,12 @@ describe('agent-transcript', () => { round: 1, text: 'Hello', thoughtText: '', + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 20, + cachedContentTokenCount: 40, + totalTokenCount: 120, + }, timestamp: Date.now(), }); cleanup(); @@ -304,9 +310,106 @@ describe('agent-transcript', () => { expect(records).toHaveLength(1); expect(records[0].type).toBe('assistant'); expect(records[0].message?.parts?.[0]).toMatchObject({ text: 'Hello' }); + expect(records[0].usageMetadata).toMatchObject({ + promptTokenCount: 100, + candidatesTokenCount: 20, + cachedContentTokenCount: 40, + }); + }); + + it('persists thought content and live stream chunks separately', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const { emitter, cleanup } = makeWriter(jsonlPath); + + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'thinking now', + thought: true, + timestamp: 1, + }); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'x'.repeat(64 * 1024), + thought: false, + timestamp: 1, + }); + emitter.emit(AgentEventType.ROUND_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'answer', + thoughtText: 'thinking now', + timestamp: 2, + }); + expect(readJsonl(jsonlPath)[0].message?.parts).toEqual([ + { text: 'thinking now', thought: true }, + { text: 'answer' }, + ]); + expect( + JSON.parse( + fs.readFileSync(`${jsonlPath}.stream`, 'utf8').trim().split('\n')[0]!, + ), + ).toMatchObject({ + runId: readJsonl(jsonlPath)[0].agentRunId, + round: 1, + text: 'thinking now', + thought: true, + timestamp: 1, + }); + expect(readJsonl(jsonlPath)[0].agentRound).toBe(1); + cleanup(); + expect(fs.existsSync(`${jsonlPath}.stream`)).toBe(false); + }); + + it('flushes live stream chunks when the pending buffer reaches 64 KiB', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const { emitter, cleanup } = makeWriter(jsonlPath); + + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'x'.repeat(32 * 1024), + thought: false, + timestamp: 1, + }); + expect(fs.existsSync(`${jsonlPath}.stream`)).toBe(false); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'y'.repeat(32 * 1024), + thought: false, + timestamp: 2, + }); + + expect(fs.existsSync(`${jsonlPath}.stream`)).toBe(true); + const records = fs + .readFileSync(`${jsonlPath}.stream`, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records).toHaveLength(2); + expect(records[1]).toMatchObject({ + round: 1, + thought: false, + timestamp: 2, + }); + cleanup(); + expect(fs.existsSync(`${jsonlPath}.stream`)).toBe(false); + }); + + it('replaces a stale stream sidecar when a writer starts', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + fs.mkdirSync(path.dirname(jsonlPath), { recursive: true }); + fs.writeFileSync(`${jsonlPath}.stream`, 'stale\n'); + + const { cleanup } = makeWriter(jsonlPath); + + expect(fs.existsSync(`${jsonlPath}.stream`)).toBe(false); + cleanup(); }); - it('drops empty ROUND_TEXT to keep the canonical view free of noise', () => { + it('drops usage-only ROUND_TEXT to keep the canonical view valid', () => { const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); const { emitter, cleanup } = makeWriter(jsonlPath); @@ -315,6 +418,7 @@ describe('agent-transcript', () => { round: 1, text: '', thoughtText: '', + usageMetadata: { totalTokenCount: 42 }, timestamp: Date.now(), }); cleanup(); diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 77506c9ede6..a3218284116 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -16,6 +16,8 @@ * notification XML points here * agent-.meta.json — sidecar with agentType, description, parent * session/agent IDs, createdAt + * agent-.jsonl.stream — transient live text, removed when the writer + * closes */ import * as fs from 'node:fs'; @@ -27,6 +29,7 @@ import { type AgentToolCallEvent, type AgentToolResponsesFinalizedEvent, type AgentRoundTextEvent, + type AgentStreamTextEvent, type AgentExternalMessageEvent, } from './runtime/agent-events.js'; import type { @@ -40,6 +43,7 @@ import { _recoverObjectsFromLine } from '../utils/jsonl-utils.js'; import type { FunctionDeclaration, Content } from '@google/genai'; const debugLogger = createDebugLogger('AGENT_TRANSCRIPT'); +const MAX_PENDING_STREAM_BYTES = 64 * 1024; export function sanitizeFilenameComponent(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -101,6 +105,8 @@ export interface AgentMeta { description: string; /** SessionId of the user session that launched this agent. */ parentSessionId: string; + /** Tool call in the parent session that launched this agent. */ + toolUseId?: string; /** AgentId of the launching subagent for nested forks; null for top-level. */ parentAgentId: string | null; /** ISO 8601 creation time. */ @@ -330,8 +336,23 @@ export function attachJsonlTranscriptWriter( ? readLastTranscriptRecordUuidSync(jsonlPath) : null; let fd: number | null = null; + let streamFd: number | null = null; + const streamPath = `${jsonlPath}.stream`; + const streamRunId = randomUUID(); + let pendingStreamText = ''; + let pendingStreamBytes = 0; + let streamFlushTimer: NodeJS.Timeout | null = null; let openFailed = false; + try { + fs.rmSync(streamPath, { force: true }); + } catch (error) { + debugLogger.warn( + `Failed to reset streaming transcript ${streamPath}:`, + error, + ); + } + const ensureOpen = (): boolean => { if (fd !== null) return true; if (openFailed) return false; @@ -371,11 +392,67 @@ export function attachJsonlTranscriptWriter( } }; + const flushStreamText = () => { + streamFlushTimer = null; + if (!pendingStreamText) return; + const text = pendingStreamText; + pendingStreamText = ''; + pendingStreamBytes = 0; + try { + if (streamFd === null) { + fs.mkdirSync(path.dirname(jsonlPath), { recursive: true }); + streamFd = fs.openSync(streamPath, 'w'); + } + fs.writeSync(streamFd, text); + } catch (error) { + debugLogger.warn( + `Failed to append streaming transcript ${streamPath}:`, + error, + ); + } + }; + + const appendStreamText = (event: AgentStreamTextEvent) => { + const record = `${JSON.stringify({ + v: 1, + runId: event.runId ?? streamRunId, + round: event.round, + text: event.text, + thought: event.thought === true, + timestamp: event.timestamp, + })}\n`; + pendingStreamText += record; + pendingStreamBytes += Buffer.byteLength(record); + if (pendingStreamBytes >= MAX_PENDING_STREAM_BYTES) { + if (streamFlushTimer !== null) { + clearTimeout(streamFlushTimer); + streamFlushTimer = null; + } + flushStreamText(); + return; + } + if (streamFlushTimer === null) { + streamFlushTimer = setTimeout(flushStreamText, 100); + streamFlushTimer.unref(); + } + }; + const onRoundText = (event: AgentRoundTextEvent) => { - if (!event.text) return; + if (!event.text && !event.thoughtText) return; append({ ...baseFields('assistant'), - message: { role: 'model', parts: [{ text: event.text }] }, + message: { + role: 'model', + parts: [ + ...(event.thoughtText + ? [{ text: event.thoughtText, thought: true }] + : []), + ...(event.text ? [{ text: event.text }] : []), + ], + }, + usageMetadata: event.usageMetadata, + agentRunId: event.runId ?? streamRunId, + agentRound: event.round, }); }; @@ -475,18 +552,25 @@ export function attachJsonlTranscriptWriter( } emitter.on(AgentEventType.ROUND_TEXT, onRoundText); + emitter.on(AgentEventType.STREAM_TEXT, appendStreamText); emitter.on(AgentEventType.TOOL_CALL, onToolCall); emitter.on(AgentEventType.TOOL_RESPONSES_FINALIZED, onToolResponsesFinalized); emitter.on(AgentEventType.EXTERNAL_MESSAGE, onExternalMessage); const cleanup = () => { emitter.off(AgentEventType.ROUND_TEXT, onRoundText); + emitter.off(AgentEventType.STREAM_TEXT, appendStreamText); emitter.off(AgentEventType.TOOL_CALL, onToolCall); emitter.off( AgentEventType.TOOL_RESPONSES_FINALIZED, onToolResponsesFinalized, ); emitter.off(AgentEventType.EXTERNAL_MESSAGE, onExternalMessage); + if (streamFlushTimer !== null) { + clearTimeout(streamFlushTimer); + streamFlushTimer = null; + } + flushStreamText(); if (fd !== null) { try { fs.closeSync(fd); @@ -495,6 +579,22 @@ export function attachJsonlTranscriptWriter( } fd = null; } + if (streamFd !== null) { + try { + fs.closeSync(streamFd); + } catch { + // Best-effort cleanup; the process will release the descriptor. + } + streamFd = null; + } + try { + fs.rmSync(streamPath, { force: true }); + } catch (error) { + debugLogger.warn( + `Failed to remove streaming transcript ${streamPath}:`, + error, + ); + } }; return { cleanup }; diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index be71aaaa20c..db5b1853f61 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -2242,7 +2242,7 @@ describe('BackgroundAgentResumeService', () => { expect(readMetaStatus(metaPath)).toBe('cancelled'); }); - it('preserves pending trailing user text in history and sends continuation as the new turn', async () => { + it('drops usage-only assistant records while preserving tool history and pending user text', async () => { const sessionId = 'session-pending-user'; const agentId = 'agent-pending-user'; const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); @@ -2271,18 +2271,65 @@ describe('BackgroundAgentResumeService', () => { message: { role: 'user', parts: [{ text: 'original task' }] }, }), JSON.stringify({ - uuid: 'a1', + uuid: 'usage-only', parentUuid: 'u1', sessionId, timestamp: '2026-04-20T00:00:00.100Z', type: 'assistant', + message: { role: 'model', parts: [] }, + usageMetadata: { totalTokenCount: 42 }, + }), + JSON.stringify({ + uuid: 'call-1', + parentUuid: 'usage-only', + sessionId, + timestamp: '2026-04-20T00:00:00.200Z', + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'read-1', + name: 'read_file', + args: { file_path: '/tmp/input.txt' }, + }, + }, + ], + }, + }), + JSON.stringify({ + uuid: 'result-1', + parentUuid: 'call-1', + sessionId, + timestamp: '2026-04-20T00:00:00.300Z', + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'read-1', + name: 'read_file', + response: { output: 'contents' }, + }, + }, + ], + }, + }), + JSON.stringify({ + uuid: 'a1', + parentUuid: 'result-1', + sessionId, + timestamp: '2026-04-20T00:00:00.400Z', + type: 'assistant', message: { role: 'model', parts: [{ text: 'working' }] }, }), JSON.stringify({ uuid: 'u2', parentUuid: 'a1', sessionId, - timestamp: '2026-04-20T00:00:00.200Z', + timestamp: '2026-04-20T00:00:00.500Z', type: 'user', message: { role: 'user', parts: [{ text: 'and another thing' }] }, }), @@ -2340,6 +2387,30 @@ describe('BackgroundAgentResumeService', () => { promptConfigOverrides: { initialMessages: [ { role: 'user', parts: [{ text: 'original task' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'read-1', + name: 'read_file', + args: { file_path: '/tmp/input.txt' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'read-1', + name: 'read_file', + response: { output: 'contents' }, + }, + }, + ], + }, { role: 'model', parts: [{ text: 'working' }] }, { role: 'user', parts: [{ text: 'and another thing' }] }, ], diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 0cf813baa0b..8fdd54775ba 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -182,9 +182,8 @@ function persistBackgroundCancellation( } function isWhitespaceOnlyAssistant(record: ChatRecord): boolean { - if (record.type !== 'assistant' || !record.message?.parts?.length) { - return false; - } + if (record.type !== 'assistant') return false; + if (!record.message?.parts?.length) return true; const hasFunctionCall = record.message.parts.some( (part) => !!part.functionCall, ); @@ -433,6 +432,7 @@ export class BackgroundAgentResumeService { : Date.now(), abortController: new AbortController(), prompt: recovery.initialPrompt, + toolUseId: meta.toolUseId, outputFile, metaPath, error: diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts index a0cd03449fe..d5fab119833 100644 --- a/packages/core/src/agents/index.ts +++ b/packages/core/src/agents/index.ts @@ -19,5 +19,9 @@ export * from './runtime/index.js'; export * from './team/index.js'; export * from './background-tasks.js'; export * from './background-agent-resume.js'; -export { getSubagentsRootDir } from './agent-transcript.js'; +export { + getSubagentSessionDir, + getSubagentsRootDir, + readAgentMeta, +} from './agent-transcript.js'; export * from './tasks/types.js'; diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 7df30758df9..86e90af9240 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -759,6 +759,7 @@ export class AgentCore { options?: ReasoningLoopOptions, ): Promise { const startTime = options?.startTimeMs ?? Date.now(); + const runId = randomUUID(); let currentMessages = initialMessages; let turnCounter = 0; let finalText = ''; @@ -912,6 +913,7 @@ export class AgentCore { if (txt) this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { subagentId: this.subagentId, + runId, round: turnCounter, text: txt, thought: isThought, @@ -997,9 +999,11 @@ export class AgentCore { if (roundText || roundThoughtText) { this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, { subagentId: this.subagentId, + runId, round: turnCounter, text: roundText, thoughtText: roundThoughtText, + usageMetadata: lastUsage, timestamp: Date.now(), } as AgentRoundTextEvent); } diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index f9d962902b0..013ae52e884 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -87,14 +87,17 @@ export interface AgentRoundEvent { export interface AgentRoundTextEvent { subagentId: string; + runId?: string; round: number; text: string; thoughtText: string; + usageMetadata?: GenerateContentResponseUsageMetadata; timestamp: number; } export interface AgentStreamTextEvent { subagentId: string; + runId?: string; round: number; text: string; /** Whether this text is reasoning/thinking content (as opposed to regular output) */ diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index ad414076fe5..cad9845a501 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -328,6 +328,10 @@ export interface ChatRecord { agentColor?: string; /** True for records produced by a subagent (a sidechain off the parent session). */ isSidechain?: boolean; + /** Writer execution that produced this subagent round. */ + agentRunId?: string; + /** Round number within agentRunId. */ + agentRound?: number; /** Source kind for injected external input records. */ externalInputKind?: 'message' | 'notification'; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index d392bd41500..0c4f13afb16 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -3033,6 +3033,7 @@ class AgentToolInvocation extends BaseToolInvocation { agentType: hookOpts.agentType, description: this.params.description, parentSessionId: sessionId, + toolUseId: this.callId, // Populated when a subagent (whose reasoning loop is wrapped in // runWithAgentContext below) launches a nested agent. Null at // top-level launches from the user session. @@ -3607,6 +3608,7 @@ class AgentToolInvocation extends BaseToolInvocation { agentType: hookOpts.agentType, description: this.params.description, parentSessionId: fgSessionId, + toolUseId: this.callId, parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), status: 'running', diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index f796936351e..ff59840762f 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -52,27 +52,27 @@ Creates a new query session with the Qwen Code. #### QueryOptions -| Option | Type | Default | Description | -| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | -| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | -| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | -| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | -| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | -| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | -| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | -| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | -| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | -| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | -| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | -| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | -| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | -| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | -| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | -| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | -| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | +| Option | Type | Default | Description | +| ------------------------ | -------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | +| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!tip] > If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections. Rule patterns such as `Bash(git *)`, `Read(.env)`, and `Edit(/src/**)` apply to `excludeTools` and `allowedTools`; `coreTools` accepts aliases but strips invocation specifiers. diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index f8de2bd4d28..a883f012e64 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -56,10 +56,10 @@ const rootDir = join(__dirname, '..'); // Bumped from 155KB to 160KB to accommodate recent growth and reduce churn, // from repeated 1KB bumps as new daemon APIs are added. // Bumped from 160KB to 161KB after merging upstream main. -// Bumped from 161KB to 165KB for the Web Shell git-diff REST helpers +// Bumped from 161KB to 167KB for the Web Shell git-diff and subagent REST helpers // (workspaceGitDiff / workspaceGitDiffFile on both client classes) and the // ChatRecord transcript projection in the default UI API. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 165 * 1024; +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 167 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index dfa62f78e74..efdd1cc6b44 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -40,6 +40,7 @@ import type { DaemonSessionExportResult, DaemonSessionTranscriptPage, DaemonSessionTranscriptPageOptions, + DaemonSubagentSessionResolution, DaemonSessionGroup, DaemonSessionGroupCatalog, DaemonSessionGroupInput, @@ -2192,6 +2193,30 @@ export class DaemonClient { ); } + async resolveSubagentSession( + sessionId: string, + toolCallId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/subagents/${urlEncode(toolCallId)}`, + 'GET /session/:id/subagents/:toolCallId', + { clientId, mode: 'rest' }, + ); + } + + async cancelSubagentSession( + sessionId: string, + toolCallId: string, + clientId?: string, + ): Promise<{ cancelled: boolean }> { + return await this.jsonRequest<{ cancelled: boolean }>( + `/session/${urlEncode(sessionId)}/subagents/${urlEncode(toolCallId)}/cancel`, + 'POST /session/:id/subagents/:toolCallId/cancel', + { clientId, mode: 'rest', method: 'POST' }, + ); + } + async resumeSession( sessionId: string, req: RestoreSessionRequest = {}, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 956fbd63a14..b3c984be882 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -821,6 +821,18 @@ export interface DaemonSessionTranscriptPage { replayError?: string; } +export interface DaemonSubagentSessionResolution { + sessionId: string; + taskId: string; + title: string; + status: string; + durationMs?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; +} + export type DaemonSessionArchiveState = 'active' | 'archived'; export type DaemonSessionGroupPresetColor = @@ -1936,6 +1948,8 @@ export interface DaemonSessionAgentTaskStatus { stats?: { totalTokens: number; toolUses: number; durationMs: number }; recentActivities?: Array<{ name: string; description: string; at: number }>; prompt?: string; + /** Tool call in the parent session that launched this agent. */ + toolUseId?: string; /** * `id` of the agent task that spawned this one. Absent for agents * launched by the top-level session. Sub-agents may spawn sub-agents diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 81fc157506c..ca3fabbaf53 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -865,7 +865,12 @@ function normalizeToolUpdate( (metadata ? getString(metadata, 'toolName') : undefined) ?? (metadata ? getString(metadata, 'name') : undefined); const toolKind = getString(update, 'kind'); - const title = getString(update, 'title') ?? toolName ?? toolKind; + const explicitTitle = getString(update, 'title'); + const title = + explicitTitle ?? + (getString(update, 'sessionUpdate') === 'tool_call' + ? (toolName ?? toolKind) + : undefined); const rawInputSource = update['rawInput'] ?? update['input'] ?? update['args']; const rawOutputSource = diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 4fbec9e3f1b..255445cf880 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -69,6 +69,8 @@ export function createDaemonTranscriptStore( reset(nextSeed: Partial = {}) { state = createState({ maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks, + retainSubagentBlocks: + nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks, ...nextSeed, }); scheduleNotify(); diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index bdc28217428..e689c48afc2 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -24,7 +24,7 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { case 'tool.update': return terminalLine( `tool ${event.status}`, - `${event.title}${event.details ? ` ${event.details}` : ''}`, + `${event.title ?? event.toolName ?? event.toolKind ?? 'Tool'}${event.details ? ` ${event.details}` : ''}`, '38;5;75', ); case 'shell.output': diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 8a00a848854..04c16752870 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -58,6 +58,7 @@ export function createDaemonTranscriptState( nextOrdinal: 1, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS, + retainSubagentBlocks: opts.retainSubagentBlocks ?? true, }; if (opts.onTruncation) truncationCallbacks.set(state, opts.onTruncation); return state; @@ -266,6 +267,7 @@ function applyDaemonTranscriptEvent( break; } case 'assistant.text.delta': + if (event.parentToolCallId && !next.retainSubagentBlocks) break; appendTextDelta( next, 'assistant', @@ -294,9 +296,14 @@ function applyDaemonTranscriptEvent( } break; case 'assistant.usage': - applyAssistantUsage(next, event); + if (event.parentToolCallId && !next.retainSubagentBlocks) { + applySubagentUsageToParentTool(next, event); + } else { + applyAssistantUsage(next, event); + } break; case 'thought.text.delta': + if (event.parentToolCallId && !next.retainSubagentBlocks) break; appendTextDelta( next, 'thought', @@ -306,6 +313,10 @@ function applyDaemonTranscriptEvent( ); break; case 'tool.update': + if (event.parentToolCallId && !next.retainSubagentBlocks) { + discardToolBlock(next, event.toolCallId); + break; + } upsertToolBlock(next, event); break; case 'shell.output': @@ -501,17 +512,9 @@ function clearActiveAssistant( } /** - * Fold a round's token usage onto the active top-level assistant block. The - * daemon emits usage right after that round's assistant text, so the active - * block is the one it belongs to; multiple rounds accumulate, and renderers sum - * a turn's blocks for the total. - * - * Sub-agent rounds (which arrive with a parentToolCallId) are folded in too: - * their tokens are part of the spawning turn's real cost, and the parent is - * blocked on the Task call while they run, so the top-level active block is - * still that turn's. Excluding them made the turn under-count badly against - * /stats. The sub-agent's own *text* still lives on its parent-keyed block; only - * the usage counter rides the top-level block. + * Fold a round's token usage onto the active top-level assistant block. + * Subagent usage stays part of the spawning turn's total for compatibility. + * Summary projections route it to the parent tool before calling this helper. * * No active block (a rare usage frame with no preceding top-level assistant * text) drops the count rather than minting a stray empty block. @@ -531,6 +534,44 @@ function applyAssistantUsage( block.updatedAt = state.now; } +function applySubagentUsageToParentTool( + state: DaemonTranscriptState, + event: Extract, +): void { + if (!event.parentToolCallId) return; + const block = getWritableBlockById( + state, + state.toolBlockByCallId[event.parentToolCallId], + ); + if (block?.kind !== 'tool') return; + const current = isRecord(block.rawOutput) ? block.rawOutput : undefined; + const currentSummary = isRecord(current?.['executionSummary']) + ? current['executionSummary'] + : undefined; + const inputTokens = + finiteNumber(currentSummary?.['inputTokens']) + event.usage.inputTokens; + const outputTokens = + finiteNumber(currentSummary?.['outputTokens']) + event.usage.outputTokens; + const cachedTokens = + finiteNumber(currentSummary?.['cachedTokens']) + + (event.usage.cachedTokens ?? 0); + block.rawOutput = { + ...(current ?? { type: 'task_execution', status: 'running' }), + executionSummary: { + ...(currentSummary ?? {}), + inputTokens, + outputTokens, + cachedTokens, + totalTokens: inputTokens + outputTokens, + }, + }; + block.updatedAt = state.now; +} + +function finiteNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + function clearActiveThought( state: DaemonTranscriptState, event?: DaemonUiEvent, @@ -691,6 +732,14 @@ function upsertToolBlock( state: DaemonTranscriptState, event: Extract, ): void { + const compactTaskOutput = + !state.retainSubagentBlocks && + isRecord(event.rawOutput) && + event.rawOutput['type'] === 'task_execution'; + let rawOutput = compactTaskExecutionOutput( + event.rawOutput, + state.retainSubagentBlocks, + ); const existingId = state.toolBlockByCallId[event.toolCallId]; if (existingId === TRIMMED_TOOL_BLOCK_ID) { if (shouldRecreateTrimmedToolBlock(event)) { @@ -724,10 +773,57 @@ function upsertToolBlock( existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; if (event.details) existing.details = event.details; - if (event.content !== undefined) existing.content = event.content; + if (compactTaskOutput) delete existing.content; + else if (event.content !== undefined) existing.content = event.content; if (event.locations !== undefined) existing.locations = event.locations; if (event.rawInput !== undefined) existing.rawInput = event.rawInput; - if (event.rawOutput !== undefined) existing.rawOutput = event.rawOutput; + if (rawOutput !== undefined) { + if ( + compactTaskOutput && + isRecord(rawOutput) && + isRecord(existing.rawOutput) + ) { + const prevSummary = isRecord(existing.rawOutput['executionSummary']) + ? existing.rawOutput['executionSummary'] + : undefined; + const nextSummary = isRecord(rawOutput['executionSummary']) + ? rawOutput['executionSummary'] + : undefined; + if (prevSummary && nextSummary) { + const inputTokens = Math.max( + finiteNumber(prevSummary['inputTokens']), + finiteNumber(nextSummary['inputTokens']), + ); + const outputTokens = Math.max( + finiteNumber(prevSummary['outputTokens']), + finiteNumber(nextSummary['outputTokens']), + ); + rawOutput = { + ...rawOutput, + executionSummary: { + ...nextSummary, + inputTokens, + outputTokens, + cachedTokens: Math.max( + finiteNumber(prevSummary['cachedTokens']), + finiteNumber(nextSummary['cachedTokens']), + ), + totalTokens: Math.max( + finiteNumber(prevSummary['totalTokens']), + finiteNumber(nextSummary['totalTokens']), + inputTokens + outputTokens, + ), + }, + }; + } else if (prevSummary) { + rawOutput = { + ...rawOutput, + executionSummary: { ...prevSummary }, + }; + } + } + existing.rawOutput = rawOutput; + } existing.sourceRecordIds = unionStrings( existing.sourceRecordIds, event.sourceRecordIds, @@ -790,10 +886,12 @@ function upsertToolBlock( ? { sourceRecordIds: [...event.sourceRecordIds] } : {}), ...(event.details ? { details: event.details } : {}), - ...(event.content !== undefined ? { content: event.content } : {}), + ...(!compactTaskOutput && event.content !== undefined + ? { content: event.content } + : {}), ...(event.locations !== undefined ? { locations: event.locations } : {}), ...(event.rawInput !== undefined ? { rawInput: event.rawInput } : {}), - ...(event.rawOutput !== undefined ? { rawOutput: event.rawOutput } : {}), + ...(rawOutput !== undefined ? { rawOutput } : {}), ...(event.toolName ? { toolName: event.toolName } : {}), ...(event.toolKind ? { toolKind: event.toolKind } : {}), ...(event.parentToolCallId @@ -831,6 +929,48 @@ function upsertToolBlock( clearActiveText(state, event.parentToolCallId); } +function discardToolBlock( + state: DaemonTranscriptState, + toolCallId: string, +): void { + const blockId = state.toolBlockByCallId[toolCallId]; + if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return; + takeBlocksOwnership(state); + state.blocks = state.blocks.filter((block) => block.id !== blockId); + state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks); + delete state.toolBlockByCallId[toolCallId]; + delete state.toolProgress[toolCallId]; + if (state.currentToolCallId === toolCallId) { + state.currentToolCallId = undefined; + } +} + +function compactTaskExecutionOutput( + rawOutput: unknown, + retainSubagentBlocks: boolean, +): unknown { + if ( + retainSubagentBlocks || + !isRecord(rawOutput) || + rawOutput['type'] !== 'task_execution' + ) { + return rawOutput; + } + const compact: Record = { type: 'task_execution' }; + for (const key of [ + 'subagentName', + 'subagentColor', + 'taskDescription', + 'status', + 'terminateReason', + 'tokenCount', + 'executionSummary', + ]) { + if (rawOutput[key] !== undefined) compact[key] = rawOutput[key]; + } + return compact; +} + /** * PR-E: maintain `state.currentToolCallId`. Sets when tool enters in-flight * status; clears when tool enters terminal status; leaves untouched for @@ -1170,6 +1310,8 @@ function cloneTranscriptState( ...state, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? state.maxBlocks, + retainSubagentBlocks: + opts.retainSubagentBlocks ?? state.retainSubagentBlocks, // Lazy copy-on-write for // `blocks` + `blockIndexById`. Eager `[...state.blocks]` defeated the // `sortedBlocksCache` / `childrenIndexCache` WeakMaps — every dispatch diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 41649045136..2e6c43e4cea 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1001,11 +1001,13 @@ export interface DaemonTranscriptState nextOrdinal: number; now: number; maxBlocks: number; + retainSubagentBlocks: boolean; } export interface DaemonTranscriptReducerOptions { maxBlocks?: number; now?: number; + retainSubagentBlocks?: boolean; onTruncation?: (detail: DaemonTranscriptTruncationDetail) => void; } diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 6a7d2e65d8f..04f2566c658 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1315,6 +1315,47 @@ describe('DaemonClient', () => { }); }); + describe('resolveSubagentSession', () => { + it('resolves an encoded parent tool call to a detail session', async () => { + const body = { + sessionId: 'subagent.virtual', + taskId: 'general-purpose-agent-1', + title: 'agent: research', + status: 'completed', + durationMs: 1_250, + totalTokens: 42, + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, body)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.resolveSubagentSession('with/slash', 'agent/1', 'client-1'), + ).resolves.toEqual(body); + + expect(calls[0]).toMatchObject({ + method: 'GET', + url: 'http://daemon/session/with%2Fslash/subagents/agent%2F1', + }); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + + it('cancels a subagent through its parent tool call', async () => { + const body = { cancelled: true }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, body)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.cancelSubagentSession('with/slash', 'agent/1', 'client-1'), + ).resolves.toEqual(body); + + expect(calls[0]).toMatchObject({ + method: 'POST', + url: 'http://daemon/session/with%2Fslash/subagents/agent%2F1/cancel', + }); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + }); + describe('session rewind transport', () => { it('reuses the negotiated native fetch for REST-only rewind calls', async () => { const negotiatedFetch = vi.fn(async (input: string | URL | Request) => { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 47da26e8aa8..e32583777e1 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -103,6 +103,65 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('preserves the initial tool title when a later update only has a tool name', () => { + const initial = normalizeDaemonEvent({ + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'tool_call', + toolCallId: 'agent-1', + title: 'agent: 查询阿里云官网信息', + status: 'in_progress', + _meta: { toolName: 'agent' }, + }, + }); + const completed = normalizeDaemonEvent({ + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'failed', + _meta: { toolName: 'agent' }, + }, + }); + + const state = reduceDaemonTranscriptEvents(createDaemonTranscriptState(), [ + ...initial, + ...completed, + ]); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + title: 'agent: 查询阿里云官网信息', + status: 'failed', + }, + ]); + }); + + it('uses the tool name when replay starts with a tool update', () => { + const events = normalizeDaemonEvent({ + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'tool_call_update', + toolCallId: 'agent-1', + status: 'in_progress', + _meta: { toolName: 'agent' }, + }, + }); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState(), + events, + ); + + expect(state.blocks).toMatchObject([ + { kind: 'tool', title: 'agent', status: 'in_progress' }, + ]); + }); + it('normalizes an in_progress frame that carries a kind (the drop is scoped to kind-less heartbeats)', () => { // The `kind === undefined` condition is load-bearing: an in_progress // frame WITH a kind is not a bare heartbeat and must pass through to a @@ -470,7 +529,7 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); - it('folds sub-agent usage (parentToolCallId) into the parent turn total', () => { + it('keeps sub-agent usage in the parent turn total by default', () => { const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), [ @@ -480,7 +539,11 @@ describe('daemon UI normalizer and transcript reducer', () => { type: 'assistant.usage', usage: { inputTokens: 100, outputTokens: 20 }, }, - // A round from a spawned sub-agent — part of the turn's real cost. + { + type: 'assistant.text.delta', + text: 'sub-agent answer', + parentToolCallId: 'sub-1', + }, { type: 'assistant.usage', usage: { inputTokens: 5000, outputTokens: 800 }, @@ -496,6 +559,11 @@ describe('daemon UI normalizer and transcript reducer', () => { text: 'answer', usage: { inputTokens: 5100, outputTokens: 820 }, }, + { + kind: 'assistant', + text: 'sub-agent answer', + parentToolCallId: 'sub-1', + }, ]); }); @@ -6848,6 +6916,232 @@ describe('daemon UI normalizer — artifact events', () => { }); describe('parallel subAgent text interleaving fix', () => { + it('drops subagent detail blocks while retaining parent usage', () => { + let state = createDaemonTranscriptState({ + now: 1, + retainSubagentBlocks: false, + }); + + state = reduceDaemonTranscriptEvents(state, [ + { type: 'assistant.text.delta', text: 'Main response' }, + { + type: 'tool.update', + toolCallId: 'agent-task-A', + toolName: 'agent', + status: 'running', + }, + { + type: 'assistant.text.delta', + text: 'Subagent answer', + parentToolCallId: 'agent-task-A', + }, + { + type: 'thought.text.delta', + text: 'Subagent thinking', + parentToolCallId: 'agent-task-A', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + status: 'completed', + parentToolCallId: 'agent-task-A', + rawOutput: 'large tool output', + }, + { + type: 'assistant.usage', + usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 40 }, + parentToolCallId: 'agent-task-A', + }, + ] as DaemonUiEvent[]); + + expect(state.blocks).toHaveLength(2); + expect(state.blocks[0]).toMatchObject({ + kind: 'assistant', + text: 'Main response', + }); + expect(state.blocks[1]).toMatchObject({ + kind: 'tool', + toolCallId: 'agent-task-A', + rawOutput: { + type: 'task_execution', + status: 'running', + executionSummary: { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + totalTokens: 120, + }, + }, + }); + + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'tool.update', + toolCallId: 'agent-task-A', + toolName: 'agent', + status: 'completed', + content: [{ type: 'content', text: 'large result' }], + rawOutput: { + type: 'task_execution', + status: 'completed', + result: 'large result', + taskPrompt: 'large prompt', + toolCalls: [{ callId: 'child-tool' }], + executionSummary: { + inputTokens: 100, + outputTokens: 20, + cachedTokens: 40, + totalTokens: 120, + }, + }, + }, + ] as DaemonUiEvent[]); + + expect( + (state.blocks[1] as { rawOutput?: Record }).rawOutput, + ).not.toMatchObject({ + result: expect.anything(), + taskPrompt: expect.anything(), + toolCalls: expect.anything(), + }); + 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, + }); + + 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: 'completed', + rawOutput: { + type: 'task_execution', + status: 'completed', + executionSummary: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + }, + }, + }, + ] 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({ + now: 1, + retainSubagentBlocks: false, + }); + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'tool.update', + toolCallId: 'agent-task-C', + toolName: 'agent', + status: 'running', + rawOutput: { + type: 'task_execution', + executionSummary: { + inputTokens: 5000, + outputTokens: 800, + totalTokens: 5800, + }, + }, + }, + ] as DaemonUiEvent[]); + const completed = { + type: 'tool.update' as const, + toolCallId: 'agent-task-C', + toolName: 'agent', + status: 'completed', + rawOutput: { + type: 'task_execution', + executionSummary: { + inputTokens: 4500, + outputTokens: 1000, + totalTokens: 5500, + }, + }, + }; + + state = reduceDaemonTranscriptEvents(state, [completed]); + + expect(state.blocks[0]).toMatchObject({ + rawOutput: { + executionSummary: { + inputTokens: 5000, + outputTokens: 1000, + totalTokens: 6000, + }, + }, + }); + expect(completed.rawOutput.executionSummary).toEqual({ + inputTokens: 4500, + outputTokens: 1000, + totalTokens: 5500, + }); + }); + + it('keeps subagent block filtering enabled after store reset', () => { + const store = createDaemonTranscriptStore({ retainSubagentBlocks: false }); + store.reset(); + store.dispatch({ + type: 'assistant.text.delta', + text: 'Subagent answer', + parentToolCallId: 'agent-task-A', + }); + + expect(store.getSnapshot().retainSubagentBlocks).toBe(false); + expect(store.getSnapshot().blocks).toHaveLength(0); + }); + it('T1: separates text chunks by parentToolCallId into independent blocks', () => { let state = createDaemonTranscriptState({ now: 1 }); diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 24e4333b692..59a072337ec 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -830,7 +830,7 @@ right: 20px; bottom: calc(100% + 8px); left: 20px; - z-index: 6; + z-index: calc(var(--web-shell-dialog-backdrop-z-index, 50) + 10); pointer-events: auto; } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 4ca61b8b7de..04a40c22c5d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4515,8 +4515,8 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('Pane artifact'); - expect(container.textContent).toContain('10 B'); + expect(document.body.textContent).toContain('Pane artifact'); + expect(document.body.textContent).toContain('10 B'); await act(async () => { container @@ -4527,7 +4527,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('20 B'); + expect(document.body.textContent).toContain('20 B'); await act(async () => { container @@ -4538,7 +4538,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('Artifact not found.'); + expect(document.body.textContent).toContain('Artifact not found.'); }); it('clears split pane artifact snapshots when switching sessions', async () => { @@ -4566,7 +4566,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('Pane artifact'); + expect(document.body.textContent).toContain('Pane artifact'); await act(async () => { mockConnection.sessionId = 'session-2'; @@ -4574,7 +4574,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - expect(container.textContent).not.toContain('Pane artifact'); + expect(document.body.textContent).not.toContain('Pane artifact'); }); it('enters the split view from a ?split= URL and consumes the param', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 2a576fa2ce1..dc0f4902b15 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -41,6 +41,7 @@ import type { import { GitForkIcon, XIcon } from 'lucide-react'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; +import { SubagentDetailsProvider } from './subagentDetailsContext'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; import { ChatEditor, @@ -84,6 +85,7 @@ import { ArtifactPanel, type ArtifactPanelTab, } from './components/artifacts/ArtifactPanel'; +import { Drawer, DrawerContent, DrawerTitle } from './components/ui/drawer'; import type { TurnOutputFileChange, TurnOutputKind, @@ -1120,6 +1122,7 @@ export function App({ // once) is only offered on large screens; below that there is no room for it // to be useful. const isLargeScreen = useIsLargeScreen(); + const canDockArtifactPanel = useIsLargeScreen('(min-width: 1001px)'); // In split view the session sidebar competes with the panes for width. Below // this width it auto-collapses to its icon rail so the panes get the room, and // expands again once the window grows back. A wide split keeps the full @@ -1864,6 +1867,56 @@ export function App({ }, [getDefaultReviewPanelWidth, t], ); + const openSubagentPanelForSession = useCallback( + (tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => { + const rawOutput = + tool.rawOutput && typeof tool.rawOutput === 'object' + ? (tool.rawOutput as Record) + : undefined; + const subagentType = + (typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined) ?? + (typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined); + const tab: ArtifactPanelTab = { + id: `subagent:${sessionId}:${tool.callId}`, + kind: 'subagent', + title: tool.title || subagentType || t('agent.label'), + sessionId, + rootToolCallId: tool.callId, + rootTool: tool, + ...(workspaceCwd ? { workspaceCwd } : {}), + }; + setArtifactPanelTabs((tabs) => + tabs.some((item) => item.id === tab.id) + ? tabs.map((item) => (item.id === tab.id ? tab : item)) + : [...tabs, tab], + ); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelWidth((width) => + artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(), + ); + setArtifactPanelOpen(true); + }, + [getDefaultReviewPanelWidth, t], + ); + const openSubagentPanel = useCallback( + (tool: ACPToolCall) => { + if (!connection.sessionId) return; + openSubagentPanelForSession( + tool, + connection.sessionId, + connection.workspaceCwd, + ); + }, + [ + connection.sessionId, + connection.workspaceCwd, + openSubagentPanelForSession, + ], + ); const handleTurnOutputOpen = useCallback( (request: TurnOutputOpenRequest) => { if (onRightPanelOpen) { @@ -1878,6 +1931,14 @@ export function App({ openScheduledTaskPanel(request.task, request.workspaceActions); return; } + if (request.kind === 'subagent') { + openSubagentPanelForSession( + request.tool, + request.sessionId, + request.workspaceCwd, + ); + return; + } if (!request.workspaceActions) { setArtifactPanelExtraArtifacts((current) => { @@ -1920,6 +1981,7 @@ export function App({ onRightPanelOpen, openReviewPanel, openScheduledTaskPanel, + openSubagentPanelForSession, ], ); const closeArtifactPanel = useCallback(() => { @@ -2344,6 +2406,8 @@ export function App({ const [mainView, setMainView] = useState< 'chat' | 'scheduledTasks' | 'goals' | 'split' >('chat'); + const useFloatingArtifactPanel = + !canDockArtifactPanel || mainView === 'split'; // Sessions to seed the split view with (e.g. the selection from the overview). const [splitSessionIds, setSplitSessionIds] = useState([]); // Latest pane list, readable from the shrink-close effect without making it a @@ -7424,7 +7488,7 @@ export function App({ .filter(Boolean) .join(' '); - const messageList = ( + const messageListContent = ( ); + const messageList = ( + + {messageListContent} + + ); const btwPanel = !showMobileWelcomeFooterMiddle && @@ -7877,7 +7948,34 @@ export function App({ - {artifactPanelOpen && ( + {artifactPanelOpen && useFloatingArtifactPanel ? ( + { + if (!open) closeArtifactPanel(); + }} + > + + Right panel + + + + ) : artifactPanelOpen ? ( <>
- )} + ) : null}
diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts index d867f9186ba..2157a7d097f 100644 --- a/packages/web-shell/client/adapters/types.ts +++ b/packages/web-shell/client/adapters/types.ts @@ -61,9 +61,9 @@ export interface TurnCollapseHead { */ elapsedMs?: number; /** - * Per-turn token usage, summed from the turn's assistant messages. Both fields - * are present together or the pair is undefined (older sessions stamp no - * usage). Sub-agent tokens are included (see the SDK reducer). + * Per-turn token usage, summed from the main assistant messages and root + * subagent execution summaries. Both fields are present together or the pair + * is undefined (older sessions stamp no usage). */ inputTokens?: number; outputTokens?: number; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index e678bf048c2..208961711a9 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -19,6 +19,8 @@ import { type DaemonWorkspaceActions, } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../adapters/types'; +import { SubagentDetailsProvider } from '../subagentDetailsContext'; import { useI18n } from '../i18n'; import { useMessages } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; @@ -140,6 +142,38 @@ export function ChatPane({ const store = useTranscriptStore(); const streamingState = useStreamingState(); const { artifacts } = useSessionArtifacts(); + const openSubagentDetails = useCallback( + (tool: ACPToolCall) => { + if (!connection.sessionId || !onRightPanelOpen) return; + const rawOutput = + tool.rawOutput && typeof tool.rawOutput === 'object' + ? (tool.rawOutput as Record) + : undefined; + const subagentType = + (typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined) ?? + (typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined); + onRightPanelOpen({ + id: `subagent:${connection.sessionId}:${tool.callId}`, + kind: 'subagent', + title: tool.title || subagentType || t('agent.label'), + turnId: tool.callId, + tool, + sessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd ?? workspaceCwd, + }); + }, + [ + connection.sessionId, + connection.workspaceCwd, + onRightPanelOpen, + t, + workspaceCwd, + ], + ); useEffect(() => { const sessionId = connection.sessionId; if (!sessionId) return; @@ -535,36 +569,40 @@ export function ChatPane({ )}
- + + +
diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index a0e5c3a1b9f..ceb7565223c 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -204,6 +204,14 @@ function mount( historyCapacityReached?: boolean; onLoadOlderHistory?: () => Promise; isResponding?: boolean; + hideFirstUserMessage?: boolean; + firstTurnMetrics?: { + durationMs?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + }; + includeSubagentToolUsageInMetrics?: boolean; onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void; customization?: WebShellCustomization; } = {}, @@ -227,6 +235,11 @@ function mount( historyCapacityReached={opts.historyCapacityReached} onLoadOlderHistory={opts.onLoadOlderHistory} isResponding={opts.isResponding} + hideFirstUserMessage={opts.hideFirstUserMessage} + firstTurnMetrics={opts.firstTurnMetrics} + includeSubagentToolUsageInMetrics={ + opts.includeSubagentToolUsageInMetrics + } onCanScrollToBottomChange={opts.onCanScrollToBottomChange} /> @@ -313,6 +326,34 @@ const simpleTurns = (count: number): Message[] => }).flat(); describe('MessageList — turn collapse (DOM)', () => { + it('hides only the first user message and overrides first-turn metrics', () => { + const c = mount( + [ + { ...userMsg('u1'), content: 'first prompt' }, + toolMsg('g1'), + asstMsg('a1'), + { ...userMsg('u2'), content: 'second prompt' }, + toolMsg('g2'), + asstMsg('a2'), + ], + undefined, + { + hideFirstUserMessage: true, + firstTurnMetrics: { + durationMs: 9_000, + inputTokens: 1_200, + outputTokens: 45, + cachedTokens: 800, + }, + }, + ); + + expect(has(c, 'u1')).toBe(false); + expect(has(c, 'u2')).toBe(true); + expect(c.textContent).toContain('9s'); + expect(c.textContent).toContain('↑1.2k (800 cached, 67%) ↓45'); + }); + it('collapses a completed turn: hides the step, keeps prompt + answer, shows the toggle', () => { const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]); expect(has(c, 'u1')).toBe(true); @@ -347,6 +388,28 @@ describe('MessageList — turn collapse (DOM)', () => { expect(text.indexOf('↓5.1k')).toBeLessThan(text.indexOf('1 tool call')); }); + it('does not add tool summary usage when full transcript usage includes it', () => { + const agent = agentMsg('nested'); + agent.tools[0]!.rawOutput = { + executionSummary: { inputTokens: 100, outputTokens: 20 }, + }; + const c = mount( + [ + userMsg('u1'), + agent, + { + ...asstMsg('a1'), + usage: { inputTokens: 100, outputTokens: 20 }, + }, + ], + undefined, + { includeSubagentToolUsageInMetrics: false }, + ); + + expect(c.textContent).toContain('↑100 ↓20'); + expect(c.textContent).not.toContain('↑200 ↓40'); + }); + it('renders step-less metrics without a toggle', () => { const c = mount([ { ...userMsg('u1'), timestamp: 1_000 }, diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index fea4b4f2c2f..7e37d57a3b4 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -85,6 +85,14 @@ interface MessageListProps { */ bottomOverlayInset?: number; hideSessionTimeline?: boolean; + hideFirstUserMessage?: boolean; + firstTurnMetrics?: { + durationMs?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + }; + includeSubagentToolUsageInMetrics?: boolean; showRetryHint?: boolean; onRetryClick?: () => void; onBranchSession?: () => void; @@ -467,6 +475,7 @@ export interface ApplyTurnCollapseOptions { * compact mode's `isForceExpandGroup`). */ pendingApprovalCallId?: string | null; + includeSubagentToolUsageInMetrics?: boolean; /** Master switch; when false the items pass through untouched. */ enabled: boolean; } @@ -1089,10 +1098,8 @@ function assistantContentTimestamp(item: DisplayItem): number | undefined { } /** - * Per-turn token usage contribution of a row. The SDK reducer folds each round's - * usage — including the sub-agent rounds a turn spawns — onto the turn's - * top-level assistant blocks, so summing the turn's assistant messages yields - * its true total cost. + * Main-agent token usage contribution of a row. Subagent usage is carried by + * the root agent tool's execution summary and is added separately below. */ function itemAssistantUsage(item: DisplayItem): | { @@ -1106,6 +1113,57 @@ function itemAssistantUsage(item: DisplayItem): : undefined; } +interface TurnTokenUsage { + inputTokens: number; + outputTokens: number; + cachedTokens?: number; +} + +function finiteTokenCount(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + +function subagentUsage( + tool: ACPToolCall, +): { callId: string; usage: TurnTokenUsage } | undefined { + if (tool.parentToolCallId || !isSubAgentToolCall(tool)) return undefined; + const raw = + tool.rawOutput && typeof tool.rawOutput === 'object' + ? (tool.rawOutput as Record) + : undefined; + const summary = + raw?.['executionSummary'] && typeof raw['executionSummary'] === 'object' + ? (raw['executionSummary'] as Record) + : undefined; + const inputTokens = finiteTokenCount(summary?.['inputTokens']); + const outputTokens = finiteTokenCount(summary?.['outputTokens']); + if (inputTokens === undefined && outputTokens === undefined) return undefined; + const cachedTokens = finiteTokenCount(summary?.['cachedTokens']); + return { + callId: tool.callId, + usage: { + inputTokens: inputTokens ?? 0, + outputTokens: outputTokens ?? 0, + ...(cachedTokens !== undefined ? { cachedTokens } : {}), + }, + }; +} + +function itemSubagentUsages( + item: DisplayItem, +): Array<{ callId: string; usage: TurnTokenUsage }> { + if (item.type === 'parallel_agents') { + return item.agents.flatMap((agent) => subagentUsage(agent) ?? []); + } + if (item.type === 'turn_content') { + return item.items.flatMap(itemSubagentUsages); + } + if (item.type !== 'message' || item.message.role !== 'tool_group') return []; + return item.message.tools.flatMap((tool) => subagentUsage(tool) ?? []); +} + function itemToolCallCount(item: DisplayItem): number { if (item.type === 'parallel_agents') return item.agents.length; if (item.type === 'turn_outputs') return 0; @@ -1253,6 +1311,7 @@ export function applyTurnCollapse( isResponding, activeTurnStartedAt, pendingApprovalCallId, + includeSubagentToolUsageInMetrics = true, enabled, }: ApplyTurnCollapseOptions, ): DisplayItem[] { @@ -1296,6 +1355,7 @@ export function applyTurnCollapse( let toolCallCount = 0; let thinkingCount = 0; let hasUsage = false; + const countedSubagents = new Set(); let hasTurnError = false; for (let i = start + 1; i <= end; i++) { const item = items[i]!; @@ -1331,6 +1391,16 @@ export function applyTurnCollapse( cachedTokens += usage.cachedTokens ?? 0; hasUsage = true; } + if (includeSubagentToolUsageInMetrics) { + for (const subagent of itemSubagentUsages(item)) { + if (countedSubagents.has(subagent.callId)) continue; + countedSubagents.add(subagent.callId); + inputTokens += subagent.usage.inputTokens; + outputTokens += subagent.usage.outputTokens; + cachedTokens += subagent.usage.cachedTokens ?? 0; + hasUsage = true; + } + } } const liveStartedAt = isActiveTurn @@ -1574,7 +1644,9 @@ type Translate = ( ) => string; function durationMetricText(elapsedMs: number | undefined): string { - return elapsedMs !== undefined ? formatDuration(elapsedMs) : ''; + return elapsedMs !== undefined && elapsedMs > 0 + ? formatDuration(elapsedMs) + : ''; } function tokenMetricText(collapse: TurnCollapseHead, t: Translate): string { @@ -2194,6 +2266,9 @@ export const MessageList = memo( autoScrollTailIntoView = false, bottomOverlayInset = 0, hideSessionTimeline = false, + hideFirstUserMessage = false, + firstTurnMetrics, + includeSubagentToolUsageInMetrics = true, showRetryHint = false, onRetryClick, onBranchSession, @@ -2401,24 +2476,63 @@ export const MessageList = memo( }, [scheduleScrollOverflowReport], ); - const visibleItems = useMemo( - () => - applyTurnCollapse(displayItems, { - overrides: collapseOverrides, - isResponding, - activeTurnStartedAt, - pendingApprovalCallId: pendingApproval?.toolCallId ?? null, - enabled: collapseEnabled, - }), - [ - displayItems, - collapseOverrides, + const visibleItems = useMemo(() => { + const collapsedItems = applyTurnCollapse(displayItems, { + overrides: collapseOverrides, isResponding, activeTurnStartedAt, - pendingApproval?.toolCallId, - collapseEnabled, - ], - ); + pendingApprovalCallId: pendingApproval?.toolCallId ?? null, + includeSubagentToolUsageInMetrics, + enabled: collapseEnabled, + }); + let metricsApplied = false; + const itemsWithMetrics = firstTurnMetrics + ? collapsedItems.map((item) => { + if (metricsApplied || item.type !== 'turn_collapse') return item; + metricsApplied = true; + return { + ...item, + turnCollapse: { + ...item.turnCollapse, + ...(firstTurnMetrics.durationMs !== undefined && + firstTurnMetrics.durationMs > 0 + ? { elapsedMs: firstTurnMetrics.durationMs } + : {}), + ...(firstTurnMetrics.inputTokens !== undefined + ? { inputTokens: firstTurnMetrics.inputTokens } + : {}), + ...(firstTurnMetrics.outputTokens !== undefined + ? { outputTokens: firstTurnMetrics.outputTokens } + : {}), + ...(firstTurnMetrics.cachedTokens !== undefined + ? { cachedTokens: firstTurnMetrics.cachedTokens } + : {}), + }, + }; + }) + : collapsedItems; + if (!hideFirstUserMessage) return itemsWithMetrics; + const firstUserId = mergedMessages.find( + (message) => message.role === 'user', + )?.id; + return firstUserId + ? itemsWithMetrics.filter( + (item) => + item.type !== 'message' || item.message.id !== firstUserId, + ) + : itemsWithMetrics; + }, [ + displayItems, + collapseOverrides, + isResponding, + activeTurnStartedAt, + pendingApproval?.toolCallId, + collapseEnabled, + hideFirstUserMessage, + firstTurnMetrics, + includeSubagentToolUsageInMetrics, + mergedMessages, + ]); const visibleTurnIdByDisplayIndex = useMemo( () => getTurnIdByDisplayIndex(visibleItems), [visibleItems], diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 5a63aa61a78..ddd4f0e6a97 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -479,6 +479,7 @@ export function SplitView({ // collide on one client identity. clientId={`split-pane:${instanceId}:${sessionId}`} historyPageSize={WEB_SHELL_HISTORY_PAGE_SIZE} + subagentTranscriptMode="summary" maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} suppressOwnUserEcho restartEventStreamOnPrompt={restartSseOnPrompt} diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index e5e7cccb00c..af99bf03d70 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -10,6 +10,7 @@ import { import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../i18n'; +import { isComposerTask } from '../utils/composerTasks'; import styles from './StatusBar.module.css'; const GOAL_PILL_INTERVAL_MS = 1000; @@ -98,9 +99,10 @@ export function getTaskPillLabel( tasks: readonly DaemonSessionTaskStatus[], t: ReturnType['t'], ): string { - if (tasks.length === 0) return ''; + const composerTasks = tasks.filter(isComposerTask); + if (composerTasks.length === 0) return ''; - const running = tasks.filter((task) => task.status === 'running'); + const running = composerTasks.filter((task) => task.status === 'running'); if (running.length > 0) { const counts = { agent: 0, shell: 0, monitor: 0 }; for (const task of running) { @@ -130,7 +132,7 @@ export function getTaskPillLabel( return parts.join(', '); } - const pausedAgents = tasks.filter( + const pausedAgents = composerTasks.filter( (task) => task.kind === 'agent' && task.status === 'paused', ); if (pausedAgents.length > 0) { @@ -142,9 +144,12 @@ export function getTaskPillLabel( ); } - return t(tasks.length === 1 ? 'tasks.pill.done' : 'tasks.pill.doneMany', { - count: tasks.length, - }); + return t( + composerTasks.length === 1 ? 'tasks.pill.done' : 'tasks.pill.doneMany', + { + count: composerTasks.length, + }, + ); } function formatGoalElapsed(ms: number): string { diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx index 19e0ebfdb66..4648ee00b59 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -232,6 +232,7 @@ export function WorkspaceSessionProvider({ workspaceCwd={targetWorkspace?.cwd} clientId={clientId} historyPageSize={WEB_SHELL_HISTORY_PAGE_SIZE} + subagentTranscriptMode="summary" maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} suppressOwnUserEcho restartEventStreamOnPrompt={restartSseOnPrompt} diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css index 34ec9ee4642..112d8d591ff 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css @@ -8,6 +8,13 @@ min-height: 0; } +.panelDrawer { + border-left: 0; + flex: 1 1 auto; + min-width: 0; + width: 100%; +} + .header { flex: 0 0 auto; display: flex; @@ -777,7 +784,7 @@ button.treeRow:hover { } @media (max-width: 900px) { - .panel { + .panel:not(.panelDrawer) { position: fixed; inset: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) auto; z-index: 60; diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index a7d282515cc..f6b4e714296 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -1,4 +1,5 @@ import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../../adapters/types'; import { useWorkspaceActions, type DaemonWorkspaceActions, @@ -42,6 +43,7 @@ import { } from './TurnOutputs'; import { LineStats, sumLineStats } from './LineStats'; import styles from './ArtifactPanel.module.css'; +import { SubagentDetail } from './SubagentDetail'; const MIN_PANEL_WIDTH_FOR_DEFAULT_TREE = 740; const MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700; @@ -75,6 +77,15 @@ export type ArtifactPanelTab = title: string; task: TurnOutputScheduledTask; workspaceActions?: DaemonWorkspaceActions; + } + | { + id: string; + kind: 'subagent'; + title: string; + sessionId: string; + rootToolCallId: string; + rootTool: ACPToolCall; + workspaceCwd?: string; }; interface ArtifactPanelProps { @@ -90,6 +101,7 @@ interface ArtifactPanelProps { onSelectTab: (tabId: string) => void; onCloseTab: (tabId: string) => void; onClose: () => void; + variant?: 'docked' | 'drawer'; } export function ArtifactPanel({ @@ -105,6 +117,7 @@ export function ArtifactPanel({ onSelectTab, onCloseTab, onClose, + variant = 'docked', }: ArtifactPanelProps) { const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; const defaultWorkspaceActions = useWorkspaceActions(); @@ -115,9 +128,11 @@ export function ArtifactPanel({ return (