From 121380de04592402c6eb7edb8da6b5347fc1e194 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Fri, 21 Aug 2026 15:52:16 -0500 Subject: [PATCH 1/2] docs: subagent live-view plans and opencode source findings The 08-06 plan's transcript-tail design is annotated as superseded: the Cursor SDK streams nested subagent activity live via taskUpdate payloads on the parent task's tool-call-delta updates, and short subagents only checkpoint their on-disk transcript at completion. --- ...6-08-06-cursor-subagent-live-transcript.md | 986 ++++++++++++++++++ ...-opencode-subagent-view-source-findings.md | 85 ++ 2 files changed, 1071 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-cursor-subagent-live-transcript.md create mode 100644 docs/superpowers/plans/2026-08-21-opencode-subagent-view-source-findings.md diff --git a/docs/superpowers/plans/2026-08-06-cursor-subagent-live-transcript.md b/docs/superpowers/plans/2026-08-06-cursor-subagent-live-transcript.md new file mode 100644 index 0000000..c289cdc --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-cursor-subagent-live-transcript.md @@ -0,0 +1,986 @@ +# Cursor Subagent Live Transcript Implementation Plan + +> **SUPERSEDED 2026-08-21.** The premise below — "Cursor's SDK emits no nested +> stream events for local subagents" — was falsified empirically: the SDK's +> `tool-call-delta` interaction update carries `taskUpdate` nested events +> (tool-start/tool-result with id+name+input) **live, mid-run**. Tool parts are +> now driven from those events in `SubagentTranscriptSink.push()` +> (subagent-stream.ts). The transcript-tail machinery (subagent-activity.ts, +> cursor-transcript.ts) was deleted: prompt-matching couldn't work (task input +> often has no `prompt`; Cursor rephrases `user_query`) and short subagents +> checkpoint only at completion (first write lands seconds after the stop +> signal). Kept from this plan: child-parts.ts (upsert incl. the required +> `state.metadata` on completed parts), the running-task stamp, the live +> child-session link. Also learned: hey-api `_client.request` resolves +> `{error}` on 4xx instead of throwing. +> See docs/superpowers/plans/2026-08-21-opencode-subagent-view-source-findings.md. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a Cursor subagent's `task` card behave like a native opencode subagent card — clickable while running, with a live `↳ ` activity subtitle — by tailing Cursor's on-disk subagent transcript and materialising real `tool` parts in the child session. + +**Architecture:** Cursor's SDK emits no nested stream events for local subagents, so live activity cannot come from the provider stream. It *is* written to disk: `~/.cursor/projects/<slug>/agent-transcripts/agent-<id>/subagents/<subid>.jsonl`, **rewritten in full on every checkpoint** during the run. We locate the file by matching its first line against the task prompt, poll it, and upsert opencode `tool` parts into the child session via `PATCH /session/{sid}/message/{mid}/part/{pid}` (`updatePart` is an upsert — `processor.ts:242` uses it to create parts). The TUI Task card reads exactly those parts. + +**Because the file is rewritten rather than appended, the reader must track progress by completed-line count, not byte offset.** A byte-offset reader stalls permanently the first time a rewrite makes the file momentarily shorter. + +**Tech Stack:** TypeScript, Node `fs`/`fs.promises`, opencode HTTP API via the SDK client's runtime `_client.request`, vitest. + +## Global Constraints + +- Node built-ins only; no new dependencies. +- Every write path is **best-effort**: a failure must never break the parent turn. But it must be **logged** via `pluginLog` — silent no-ops are what made the current bugs undiagnosable. +- Transcript content is **never truncated** (established decision). +- Never write to a child session after `finalize()`. +- Polling interval: 400ms. Never busy-wait. +- All new behaviour is TDD'd against fixtures captured from real files under `test/fixtures/`. + +--- + +## Evidence This Plan Rests On + +Verified by reading, with locations: + +- **TUI subtitle source** — `packages/tui/src/routes/session/index.tsx:2227-2279`. `tools()` collects `type === "tool"` parts across **all** child-session messages (no role filter). `current()` = last tool part with `state.title`. Subtitle renders `↳ ${titlecase(tool)} ${title}`, else `↳ ${formatSubagentToolcalls(n)}`. +- **Card→child link** — same file, line 2220/2224: `props.metadata.sessionId`, i.e. the parent task part's `state.metadata.sessionId`. +- **`updatePart` upserts** — `packages/opencode/src/session/processor.ts:242` creates parts through `session.updatePart({id: PartID.ascending(), ...})`. +- **PATCH validation** — `httpapi/handlers/session.ts:397-411` requires `payload.id/messageID/sessionID` to equal the path params; only `requireSession` is checked. +- **No message-create endpoint** — `httpapi/groups/session.ts:111-433` exposes `updatePart`, `deletePart`, `deleteMessage`; the only message-creating routes (`prompt`, `promptAsync`, `command`, `shell`) invoke a model. +- **PartID format** — `packages/opencode/src/id/id.ts:51-70`: `prefix + "_" + 6 timestamp bytes as hex + 14 random base62`. Confirmed against real row `prt_fd90281ed001Zwm05cey7wh2ym`. +- **ToolPart shape** — `packages/opencode/src/session/prompt.ts:283-299` (running) and `:335-339` (metadata/title merge). +- **Transcript is written during the run** — `@cursor/sdk/dist/esm/357.js` @294387, inside `LocalSubagentHostAdapter`: `handleCheckpoint: (e,t) => { yield agentStore.handleCheckpoint(e,t); transcriptWriter.writeFromState(e,t) }`. `writeFromState` (@301742) calls `transcriptStore.writeFromStateFull` and then loops nested subagent states into `nestedSubagentTranscriptStore.writeFromStateFull`. So every checkpoint rewrites the subagent transcript. +- **It is a full rewrite, not an append** — the store is constructed at @301152 as `{writeText:false, writeJsonl:true, pathResolver}` with **no `appendFile`**. `writeFromStateIncremental`'s fast path requires `options.appendFile`, so it always falls back to `writeFromStateFull`. +- **Line format** — builder at @139161 emits `{role, message:{content:[{type:"text",text}|{type:"tool_use",name,input}]}}`. +- **Real transcript sample** — `agent-b6786a00-…/subagents/edf3c300-….jsonl` (the 2026-08-06 16:37 changelog run): line 0 `user` with `<user_query>`, lines 1-3 `assistant` with `text` and `tool_use` blocks (`GetMcpTools`, `Read`, `CallMcpTool`). + +**Verified statically, still worth one empirical confirmation** (Task 1 Step 3): that checkpoints fire often enough mid-run to be useful. The code path is proven; the *cadence* is not. + +## Implementation Status (updated during execution) + +Tasks 2-6 are **implemented**; Task 1 (the live stamp fix) is **outstanding** and still gates whether any of this is visible. + +Two design changes were forced during implementation, both by tests: + +1. **Correlation is by mtime, not by a snapshot of pre-existing files.** The + original `known: Set<string>` design raced: a subagent that checkpointed + before the snapshot was taken would be excluded from matching *forever*. The + replacement, `since: number`, rejects a previous run with the same prompt + (Cursor never rewrites a finished subagent's transcript) without that race. + `knownTranscripts` was removed. +2. **The reader tracks completed lines, not byte offset** — see Architecture. + +Files as built: `src/provider/child-parts.ts`, `src/provider/cursor-transcript.ts`, +`src/provider/subagent-activity.ts`; modified `src/provider/subagent-bridge.ts` +and `src/provider/stream-map.ts`. Tests: `test/child-parts.test.ts`, +`test/cursor-transcript.test.ts`, `test/subagent-activity.test.ts`, plus +additions to `test/subagent-bridge.test.ts` and `test/stream-map.test.ts`. + +## Known Limitation (accept or renegotiate before starting) + +There is no endpoint to create an **assistant** message, so synthesized tool parts must attach to the existing user message created by the `noReply` prompt. Consequence: the TUI's `duration` memo (`index.tsx:2253-2258`) needs a `role === "assistant"` message with `time.completed` and will keep reporting `0`. The completed line will read "N tool calls" with a wrong duration. This is why `activityLine` (our own `_Subagent ran N steps in Xs._` message) is retained. + +## File Structure + +- **Create** `src/provider/cursor-transcript.ts` — locating and tailing Cursor's subagent JSONL. Pure filesystem + parsing; no opencode types. +- **Create** `src/provider/child-parts.ts` — part-id generation and the `updatePart` upsert. Pure opencode-side; no Cursor types. +- **Modify** `src/provider/subagent-bridge.ts` — `SubagentLiveSession` gains `messageID` + `toolPart()`; fix live stamping. +- **Modify** `src/provider/stream-map.ts` — start the tail on task tool-call, stop it on tool-result. +- **Test** `test/cursor-transcript.test.ts`, `test/child-parts.test.ts`, plus additions to `test/subagent-bridge.test.ts`. + +Keeping Cursor-side and opencode-side concerns in separate files matters here: they have independent failure modes and each is testable without the other. + +--- + +### Task 1: Diagnose the live stamp and confirm transcript liveness + +**Blocking.** Everything else depends on `state.metadata.sessionId` being set while the task part is `running` (otherwise `sessionID()` is undefined, `messages()` is empty, and no subtitle can render regardless of what we write). Diagnostics for this already shipped in `subagent-bridge.ts` (`skipStamp`, "stamped task part", "task part stamp failed"). + +**Files:** + +- Read: `~/.local/share/opencode/log/opencode.log` +- Modify: `src/provider/subagent-bridge.ts` (fix depends on finding) + +- [ ] **Step 1: Restart opencode in this worktree and run one Cursor subagent task** + +The running process must postdate the build. Confirm: + +```bash +for pid in $(pgrep -x opencode); do + cwd=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | grep '^n' | cut -c2-) + case "$cwd" in *subagent-output-into-subagent-view*) ps -o pid,lstart= -p "$pid";; esac +done +stat -f '%Sm %N' dist/provider/index.js +``` + +Expected: process start time is later than the dist mtime. + +- [ ] **Step 2: Read the stamp diagnostics** + +```bash +grep -E "subagent: task part stamp" ~/.local/share/opencode/log/opencode.log | tail -20 +``` + +Expected: exactly one of — + +- `stamp skipped {reason: "event state pending"}` → the hook sees only `pending`; fix by accepting `pending` as stampable (a pending part is still pre-completion) and re-reading before PATCH. +- `stamp skipped {reason: "no bridge"}` → `setSubagentBridge` is not called on this path; wire it. +- `stamp failed {error: …}` → the PATCH is rejected; fix the payload per `handlers/session.ts:397-411`. +- No lines at all → the `event` hook never fires for this part; verify the plugin is loaded and `part.callID` matches the registered id by logging both. + +- [ ] **Step 3: Confirm the transcript grows during the run** + +While a subagent task is running, in a second shell: + +```bash +BASE=~/.cursor/projects/<project-slug>/agent-transcripts +watch -n1 'find '"$BASE"' -path "*/subagents/*.jsonl" -newermt "-2 minutes" -exec wc -l {} \;' +``` + +Expected: line count increases while the subagent is still running. + +The write path is already proven (see Evidence); this measures **cadence**. If the file only reaches its final size at completion, the subtitle will appear late rather than never — report the observed cadence before continuing to Task 6. + +- [ ] **Step 4: Apply the stamp fix indicated by Step 2, then verify** + +Re-run a task; the card must be `ctrl+x down` navigable *before* the subagent finishes. + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/subagent-bridge.ts +git commit -m "fix: stamp child session id on the running task part" +``` + +--- + +### Task 2: Part id generation and tool-part upsert + +**Files:** + +- Create: `src/provider/child-parts.ts` +- Test: `test/child-parts.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: + - `createPartID(now?: number): string` + - `upsertToolPart(opts: { sessionID: string; messageID: string; partID: string; callID: string; tool: string; status: "running" | "completed"; title?: string; input?: unknown; output?: string; start: number; end?: number }): Promise<boolean>` — resolves `true` on a successful PATCH, `false` otherwise. Never throws. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from "vitest"; +import { createPartID } from "../src/provider/child-parts.js"; + +describe("createPartID", () => { + it("matches opencode's ascending part id format", () => { + // packages/opencode/src/id/id.ts:51 — prefix + "_" + 6 hex bytes + 14 base62 + expect(createPartID()).toMatch(/^prt_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + }); + + it("is monotonic within the same millisecond", () => { + const ids = Array.from({ length: 50 }, () => createPartID(1786052248042)); + expect([...ids].sort()).toEqual(ids); + expect(new Set(ids).size).toBe(50); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/child-parts.test.ts` +Expected: FAIL — cannot resolve `../src/provider/child-parts.js`. + +- [ ] **Step 3: Implement `createPartID`** + +```typescript +import { randomBytes } from "node:crypto"; + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const RANDOM_LENGTH = 14; + +let lastTimestamp = 0; +let counter = 0; + +/** + * Generate an opencode-compatible ascending part id. Mirrors + * `packages/opencode/src/id/id.ts:51` — a 6-byte big-endian + * `timestamp * 0x1000 + counter` in hex, then random base62. The counter keeps + * ids monotonic (and unique) within a millisecond, which is what makes parts + * sort correctly in the TUI. + */ +export function createPartID(now?: number): string { + const timestamp = now ?? Date.now(); + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp; + counter = 0; + } + counter++; + const value = BigInt(timestamp) * BigInt(0x1000) + BigInt(counter); + const bytes = Buffer.alloc(6); + for (let i = 0; i < 6; i++) { + bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff)); + } + let random = ""; + const raw = randomBytes(RANDOM_LENGTH); + for (let i = 0; i < RANDOM_LENGTH; i++) random += BASE62[raw[i]! % 62]; + return `prt_${bytes.toString("hex")}${random}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/child-parts.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the failing test for `upsertToolPart`** + +```typescript +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearSubagentBridge, setSubagentBridge } from "../src/provider/subagent-bridge.js"; +import { createPartID, upsertToolPart } from "../src/provider/child-parts.js"; + +afterEach(() => clearSubagentBridge()); + +function fakeBridge() { + const calls: any[] = []; + const request = vi.fn(async (opts: any) => { calls.push(opts); return {}; }); + setSubagentBridge({ client: { _client: { request } } as any, directory: "/w" }); + return { calls }; +} + +describe("upsertToolPart", () => { + it("PATCHes a running tool part with a title", async () => { + const { calls } = fakeBridge(); + const ok = await upsertToolPart({ + sessionID: "ses_c", messageID: "msg_1", partID: createPartID(), + callID: "c1", tool: "read", status: "running", + title: "CHANGELOG.md", input: { path: "CHANGELOG.md" }, start: 5, + }); + expect(ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("PATCH"); + expect(calls[0].url).toBe("/session/{sessionID}/message/{messageID}/part/{partID}"); + expect(calls[0].body.type).toBe("tool"); + expect(calls[0].body.tool).toBe("read"); + expect(calls[0].body.state).toMatchObject({ + status: "running", title: "CHANGELOG.md", time: { start: 5 }, + }); + }); + + it("sends the path params the endpoint validates against the body", async () => { + const { calls } = fakeBridge(); + const partID = createPartID(); + await upsertToolPart({ + sessionID: "ses_c", messageID: "msg_1", partID, callID: "c1", + tool: "bash", status: "completed", title: "git status", + output: "clean", start: 1, end: 2, + }); + // handlers/session.ts:403-409 rejects the request unless these match. + expect(calls[0].path).toEqual({ sessionID: "ses_c", messageID: "msg_1", partID }); + expect(calls[0].body.id).toBe(partID); + expect(calls[0].body.messageID).toBe("msg_1"); + expect(calls[0].body.sessionID).toBe("ses_c"); + expect(calls[0].body.state.status).toBe("completed"); + expect(calls[0].body.state.output).toBe("clean"); + }); + + it("returns false and never throws when the request fails", async () => { + const request = vi.fn(async () => { throw new Error("boom"); }); + setSubagentBridge({ client: { _client: { request } } as any }); + const ok = await upsertToolPart({ + sessionID: "s", messageID: "m", partID: createPartID(), + callID: "c", tool: "read", status: "running", start: 0, + }); + expect(ok).toBe(false); + }); + + it("returns false when no bridge is published", async () => { + const ok = await upsertToolPart({ + sessionID: "s", messageID: "m", partID: createPartID(), + callID: "c", tool: "read", status: "running", start: 0, + }); + expect(ok).toBe(false); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npx vitest run test/child-parts.test.ts` +Expected: FAIL — `upsertToolPart` is not exported. + +- [ ] **Step 7: Implement `upsertToolPart`** + +```typescript +import { getSubagentBridge } from "./subagent-bridge.js"; +import { pluginLog } from "./log-bridge.js"; + +const PART_URL = "/session/{sessionID}/message/{messageID}/part/{partID}"; + +/** + * Create or update a `tool` part in a child session. `updatePart` is an upsert + * (`processor.ts:242` creates parts through it), so a fresh `partID` inserts. + * The TUI's subagent card reads exactly these parts to render its activity + * subtitle (`routes/session/index.tsx:2227-2279`). + * + * Best-effort: never throws, but always logs — a silent failure here is + * indistinguishable from "the subagent did nothing". + */ +export async function upsertToolPart(opts: { + sessionID: string; + messageID: string; + partID: string; + callID: string; + tool: string; + status: "running" | "completed"; + title?: string; + input?: unknown; + output?: string; + start: number; + end?: number; +}): Promise<boolean> { + const bridge = getSubagentBridge(); + const request = (bridge?.client as unknown as { + _client?: { request?: (o: Record<string, unknown>) => Promise<unknown> }; + } | undefined)?._client?.request; + if (!bridge || !request) { + pluginLog("debug", "subagent: tool part skipped", { reason: "no bridge", tool: opts.tool }); + return false; + } + const state: Record<string, unknown> = { + status: opts.status, + input: opts.input ?? {}, + time: opts.status === "completed" ? { start: opts.start, end: opts.end ?? Date.now() } : { start: opts.start }, + }; + if (opts.title) state["title"] = opts.title; + if (opts.status === "completed") state["output"] = opts.output ?? ""; + try { + await request({ + method: "PATCH", + url: PART_URL, + path: { sessionID: opts.sessionID, messageID: opts.messageID, partID: opts.partID }, + ...(bridge.directory ? { query: { directory: bridge.directory } } : {}), + body: { + id: opts.partID, + messageID: opts.messageID, + sessionID: opts.sessionID, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }); + return true; + } catch (err) { + pluginLog("warn", "subagent: tool part upsert failed", { + tool: opts.tool, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx vitest run test/child-parts.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 9: Commit** + +```bash +git add src/provider/child-parts.ts test/child-parts.test.ts +git commit -m "feat: upsert tool parts into subagent child sessions" +``` + +--- + +### Task 3: Parse Cursor subagent transcript lines + +**Files:** + +- Create: `src/provider/cursor-transcript.ts` +- Test: `test/cursor-transcript.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: + - `type TranscriptEntry = { kind: "text"; role: string; text: string } | { kind: "tool"; role: string; name: string; input: unknown }` + - `parseTranscriptLine(line: string): TranscriptEntry[]` — `[]` for blank/unparseable lines. + - `toolTitle(input: unknown): string | undefined` — a short human label from a tool input. + +- [ ] **Step 1: Write the failing test** + +Shapes copied verbatim from the real 16:37 run +(`agent-b6786a00-…/subagents/edf3c300-….jsonl`). + +```typescript +import { describe, expect, it } from "vitest"; +import { parseTranscriptLine, toolTitle } from "../src/provider/cursor-transcript.js"; + +describe("parseTranscriptLine", () => { + it("extracts assistant text", () => { + const line = JSON.stringify({ + role: "assistant", + message: { content: [{ type: "text", text: "Using navigating-codebases." }] }, + }); + expect(parseTranscriptLine(line)).toEqual([ + { kind: "text", role: "assistant", text: "Using navigating-codebases." }, + ]); + }); + + it("extracts multiple tool_use blocks from one line in order", () => { + const line = JSON.stringify({ + role: "assistant", + message: { + content: [ + { type: "text", text: "working" }, + { type: "tool_use", name: "GetMcpTools", input: { server: "context-mode" } }, + { type: "tool_use", name: "Read", input: { path: "/tmp/SKILL.md", limit: 40 } }, + ], + }, + }); + const out = parseTranscriptLine(line); + expect(out).toHaveLength(3); + expect(out[1]).toEqual({ kind: "tool", role: "assistant", name: "GetMcpTools", input: { server: "context-mode" } }); + expect(out[2]).toMatchObject({ kind: "tool", name: "Read" }); + }); + + it("returns [] for blank or malformed lines", () => { + expect(parseTranscriptLine("")).toEqual([]); + expect(parseTranscriptLine(" ")).toEqual([]); + expect(parseTranscriptLine("{not json")).toEqual([]); + expect(parseTranscriptLine(JSON.stringify({ role: "user" }))).toEqual([]); + }); +}); + +describe("toolTitle", () => { + it("prefers a path", () => { + expect(toolTitle({ path: "/a/b/CHANGELOG.md", limit: 40 })).toBe("/a/b/CHANGELOG.md"); + }); + + it("falls back through command, pattern, query, then server", () => { + expect(toolTitle({ command: "git status" })).toBe("git status"); + expect(toolTitle({ pattern: "TODO" })).toBe("TODO"); + expect(toolTitle({ query: "how does x work" })).toBe("how does x work"); + expect(toolTitle({ server: "context-mode", toolName: "ctx_execute" })).toBe("context-mode"); + }); + + it("returns undefined when nothing is recognisable", () => { + expect(toolTitle({})).toBeUndefined(); + expect(toolTitle(undefined)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/cursor-transcript.test.ts` +Expected: FAIL — cannot resolve `../src/provider/cursor-transcript.js`. + +- [ ] **Step 3: Implement the parser** + +```typescript +/** One renderable item from a Cursor subagent transcript line. */ +export type TranscriptEntry = + | { kind: "text"; role: string; text: string } + | { kind: "tool"; role: string; name: string; input: unknown }; + +/** Keys a Cursor tool input may carry, best-title-first. */ +const TITLE_KEYS = ["path", "command", "pattern", "query", "server"] as const; + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null; +} + +/** + * Parse one line of Cursor's subagent transcript JSONL. The SDK writes + * `{role, message: {content: [...]}}` per turn (`357.js`, TranscriptStore), + * where a block is `{type:"text", text}` or `{type:"tool_use", name, input}`. + * Unparseable lines yield `[]` — the file is appended to while we read it, so + * a torn final line is expected and must never throw. + */ +export function parseTranscriptLine(line: string): TranscriptEntry[] { + if (!line.trim()) return []; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (!isRecord(parsed)) return []; + const role = typeof parsed["role"] === "string" ? parsed["role"] : "assistant"; + const message = parsed["message"]; + const content = isRecord(message) ? message["content"] : undefined; + if (!Array.isArray(content)) return []; + const out: TranscriptEntry[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block["type"] === "text" && typeof block["text"] === "string" && block["text"]) { + out.push({ kind: "text", role, text: block["text"] }); + } else if (block["type"] === "tool_use" && typeof block["name"] === "string") { + out.push({ kind: "tool", role, name: block["name"], input: block["input"] }); + } + } + return out; +} + +/** Derive a short label for a tool call, mirroring opencode's `state.title`. */ +export function toolTitle(input: unknown): string | undefined { + if (!isRecord(input)) return undefined; + for (const key of TITLE_KEYS) { + const value = input[key]; + if (typeof value === "string" && value) return value; + } + return undefined; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/cursor-transcript.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/cursor-transcript.ts test/cursor-transcript.test.ts +git commit -m "feat: parse cursor subagent transcript lines" +``` + +--- + +### Task 4: Locate and tail the subagent transcript + +**Files:** + +- Modify: `src/provider/cursor-transcript.ts` +- Test: `test/cursor-transcript.test.ts` + +**Interfaces:** + +- Consumes: `parseTranscriptLine` (Task 3). +- Produces: + - `cursorProjectDir(cwd: string, home?: string): string` + - `findSubagentTranscript(opts: { projectDir: string; prompt: string; known: Set<string> }): Promise<string | undefined>` + - `tailTranscript(opts: { file: string; onEntry: (e: TranscriptEntry) => void; signal: { stopped: boolean }; intervalMs?: number }): Promise<void>` + +Correlation is by **prompt match**, not by filename: Cursor names files by subagent uuid, and concurrent subagents would otherwise be indistinguishable. Line 0 of each transcript embeds the task prompt inside `<user_query>`. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cursorProjectDir, findSubagentTranscript, tailTranscript } from "../src/provider/cursor-transcript.js"; + +describe("cursorProjectDir", () => { + it("slugs the cwd the way Cursor does", () => { + // Observed: /Users/you/orca/ws -> Users-you-orca-ws + expect(cursorProjectDir("/Users/j/orca/ws", "/Users/j")).toBe( + "/Users/j/.cursor/projects/Users-j-orca-ws", + ); + }); +}); + +function seed() { + const root = mkdtempSync(join(tmpdir(), "cursor-tx-")); + const subs = join(root, "agent-transcripts", "agent-a", "subagents"); + mkdirSync(subs, { recursive: true }); + return { root, subs }; +} + +describe("findSubagentTranscript", () => { + it("finds the file whose first line contains the prompt", async () => { + const { root, subs } = seed(); + writeFileSync(join(subs, "other.jsonl"), JSON.stringify({ + role: "user", message: { content: [{ type: "text", text: "<user_query>something else</user_query>" }] }, + }) + "\n"); + writeFileSync(join(subs, "mine.jsonl"), JSON.stringify({ + role: "user", message: { content: [{ type: "text", text: "<user_query>Read the CHANGELOG</user_query>" }] }, + }) + "\n"); + const found = await findSubagentTranscript({ + projectDir: root, prompt: "Read the CHANGELOG", known: new Set(), + }); + expect(found).toBe(join(subs, "mine.jsonl")); + }); + + it("ignores files that existed before the task started", async () => { + const { root, subs } = seed(); + const stale = join(subs, "stale.jsonl"); + writeFileSync(stale, JSON.stringify({ + role: "user", message: { content: [{ type: "text", text: "<user_query>Read the CHANGELOG</user_query>" }] }, + }) + "\n"); + const found = await findSubagentTranscript({ + projectDir: root, prompt: "Read the CHANGELOG", known: new Set([stale]), + }); + expect(found).toBeUndefined(); + }); + + it("returns undefined when the directory does not exist", async () => { + const found = await findSubagentTranscript({ + projectDir: "/nope/nowhere", prompt: "x", known: new Set(), + }); + expect(found).toBeUndefined(); + }); +}); + +describe("tailTranscript", () => { + it("emits entries appended after it starts, then stops on signal", async () => { + const { subs } = seed(); + const file = join(subs, "live.jsonl"); + writeFileSync(file, ""); + const seen: string[] = []; + const signal = { stopped: false }; + const done = tailTranscript({ + file, + intervalMs: 10, + signal, + onEntry: (e) => { if (e.kind === "tool") seen.push(e.name); }, + }); + appendFileSync(file, JSON.stringify({ + role: "assistant", message: { content: [{ type: "tool_use", name: "Read", input: { path: "a" } }] }, + }) + "\n"); + await new Promise((r) => setTimeout(r, 60)); + appendFileSync(file, JSON.stringify({ + role: "assistant", message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] }, + }) + "\n"); + await new Promise((r) => setTimeout(r, 60)); + signal.stopped = true; + await done; + expect(seen).toEqual(["Read", "Bash"]); + }); + + it("does not re-emit entries it already saw", async () => { + const { subs } = seed(); + const file = join(subs, "once.jsonl"); + writeFileSync(file, JSON.stringify({ + role: "assistant", message: { content: [{ type: "tool_use", name: "Read", input: {} }] }, + }) + "\n"); + const seen: string[] = []; + const signal = { stopped: false }; + const done = tailTranscript({ file, intervalMs: 10, signal, onEntry: (e) => seen.push(e.kind) }); + await new Promise((r) => setTimeout(r, 60)); + signal.stopped = true; + await done; + expect(seen).toEqual(["tool"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/cursor-transcript.test.ts` +Expected: FAIL — `cursorProjectDir` is not exported. + +- [ ] **Step 3: Implement locating and tailing** + +```typescript +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const POLL_INTERVAL_MS = 400; + +/** + * Cursor's per-project state directory. The slug is the absolute cwd with the + * leading separator dropped and the rest replaced by `-` (observed: + * `/Users/you/orca/…` -> `Users-you-orca-…`). + */ +export function cursorProjectDir(cwd: string, home = homedir()): string { + const slug = cwd.replace(/^\/+/, "").replace(/\//g, "-"); + return join(home, ".cursor", "projects", slug); +} + +async function subagentFiles(projectDir: string): Promise<string[]> { + const root = join(projectDir, "agent-transcripts"); + const out: string[] = []; + let agents: string[]; + try { + agents = await readdir(root); + } catch { + return out; + } + for (const agent of agents) { + const dir = join(root, agent, "subagents"); + try { + for (const file of await readdir(dir)) { + if (file.endsWith(".jsonl")) out.push(join(dir, file)); + } + } catch { + // Not every agent has subagents. + } + } + return out; +} + +/** + * Find the transcript for a specific task by matching the prompt embedded in + * the transcript's first line (`<user_query>…`). Filenames are subagent uuids, + * so with concurrent subagents the prompt is the only reliable correlator. + * `known` holds files that existed when the task started; they are skipped so + * a previous run's transcript is never adopted. + */ +export async function findSubagentTranscript(opts: { + projectDir: string; + prompt: string; + known: Set<string>; +}): Promise<string | undefined> { + const needle = opts.prompt.trim().slice(0, 200); + if (!needle) return undefined; + for (const file of await subagentFiles(opts.projectDir)) { + if (opts.known.has(file)) continue; + try { + const head = (await readFile(file, "utf8")).split("\n", 1)[0] ?? ""; + if (head.includes(needle)) return file; + } catch { + // Being written right now; try again on the next poll. + } + } + return undefined; +} + +/** Snapshot the transcripts that already exist, to skip them later. */ +export async function knownTranscripts(projectDir: string): Promise<Set<string>> { + return new Set(await subagentFiles(projectDir)); +} + +/** + * Poll a transcript for new content until `signal.stopped`. + * + * Cursor rewrites this file in full on every checkpoint (it constructs the + * store without `appendFile`, so `writeFromStateIncremental` always falls back + * to `writeFromStateFull`). Progress is therefore tracked by the number of + * COMPLETE lines already emitted, never by byte offset: a rewrite can make the + * file momentarily shorter, which would stall an offset-based reader forever. + * + * A trailing fragment without a newline is a half-written final line and is + * not counted, so it is re-read once complete. + */ +export async function tailTranscript(opts: { + file: string; + onEntry: (entry: TranscriptEntry) => void; + signal: { stopped: boolean }; + intervalMs?: number; +}): Promise<void> { + const interval = opts.intervalMs ?? POLL_INTERVAL_MS; + let emitted = 0; + while (!opts.signal.stopped) { + try { + const text = await readFile(opts.file, "utf8"); + const lines = text.split("\n"); + // A trailing "" means the text ended with a newline, so every remaining + // element is a complete line; otherwise the last element is a fragment. + const complete = lines[lines.length - 1] === "" ? lines.length - 1 : lines.length - 1; + for (let i = emitted; i < complete; i++) { + for (const entry of parseTranscriptLine(lines[i] ?? "")) opts.onEntry(entry); + } + if (complete > emitted) emitted = complete; + } catch { + // File may not exist yet or be mid-rewrite; retry next tick. + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/cursor-transcript.test.ts` +Expected: PASS (12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/cursor-transcript.ts test/cursor-transcript.test.ts +git commit -m "feat: locate and tail cursor subagent transcripts" +``` + +--- + +### Task 5: Expose the child message id and a tool-part writer + +`upsertToolPart` needs a `messageID` in the child session. `linkSubagentSessionLive` currently discards the prompt response. + +**Files:** + +- Modify: `src/provider/subagent-bridge.ts:527-586` +- Test: `test/subagent-bridge.test.ts` + +**Interfaces:** + +- Consumes: `upsertToolPart`, `createPartID` (Task 2). +- Produces: `SubagentLiveSession` gains + - `messageID: string | undefined` + - `toolPart(opts: { callID: string; tool: string; title?: string; input?: unknown; status: "running" | "completed"; partID?: string; start: number }): Promise<string | undefined>` — returns the part id used, so the caller can flip the same part to `completed`. + +- [ ] **Step 1: Write the failing test** + +```typescript +it("captures the seeded message id and writes tool parts", async () => { + const request = vi.fn(async () => ({})); + const prompt = vi.fn(async () => ({ data: { info: { id: "msg_seed" } } })); + const create = vi.fn(async () => ({ data: { id: "ses_child" } })); + setSubagentBridge({ + client: { session: { create, prompt }, _client: { request } } as any, + directory: "/w", + }); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "do the thing" }, + }); + expect(live?.messageID).toBe("msg_seed"); + const partID = await live!.toolPart({ + callID: "c1", tool: "read", title: "a.ts", status: "running", start: 1, + }); + expect(partID).toMatch(/^prt_/); + expect(request).toHaveBeenCalledTimes(1); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/subagent-bridge.test.ts` +Expected: FAIL — `live.messageID` is `undefined`. + +- [ ] **Step 3: Capture the message id and add `toolPart`** + +Inspect the real `session.prompt` response before finalising the accessor — log `JSON.stringify(res?.data)` once and read the actual key (`data.info.id` is the expectation; correct the code if it differs). Then, inside `linkSubagentSessionLive`, replace the seeding block: + +```typescript + let messageID: string | undefined; + const prompt = strField(opts.args, "prompt"); + if (prompt) { + const seeded = await client.session.prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text: prompt }] }, + }); + messageID = strField((seeded?.data as { info?: unknown } | undefined)?.info, "id"); + } +``` + +and extend the returned object: + +```typescript + messageID, + toolPart: async (o) => { + if (done || !messageID) return undefined; + const partID = o.partID ?? createPartID(); + const ok = await upsertToolPart({ + sessionID: childId, + messageID, + partID, + callID: o.callID, + tool: o.tool, + status: o.status, + title: o.title, + input: o.input, + start: o.start, + }); + return ok ? partID : undefined; + }, +``` + +Add `messageID` and `toolPart` to the `SubagentLiveSession` interface, and import `createPartID`/`upsertToolPart` from `./child-parts.js`. + +- [ ] **Step 4: Run the full suite** + +Run: `npx vitest run` +Expected: PASS, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/subagent-bridge.ts test/subagent-bridge.test.ts +git commit -m "feat: write tool parts into the subagent child session" +``` + +--- + +### Task 6: Wire the tail into the task lifecycle + +**Files:** + +- Modify: `src/provider/stream-map.ts:1240-1254` (start) and `:1320-1342` (stop) +- Test: `test/stream-map.test.ts` + +**Interfaces:** + +- Consumes: everything above. +- Produces: no new exports. + +- [ ] **Step 1: Write the failing test** + +```typescript +it("stops tailing when the subagent task completes", async () => { + // Drive cursorEventsToStream with a task tool-call then tool-result and + // assert the tail signal is flipped, so no timer outlives the turn. + // (Model on the existing subagent test at test/stream-map.test.ts:1740.) +}); +``` + +Fill this in against the existing helper in that file — assert that after the `tool-result` event the registered tail signal has `stopped === true`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/stream-map.test.ts` +Expected: FAIL — the signal stays `false`. + +- [ ] **Step 3: Start the tail alongside the existing live link** + +Immediately after `registerSubagentCall(event.id, live.childId)`: + +```typescript + const signal = { stopped: false }; + subagentTails.set(event.id, signal); + void startSubagentTail({ + live, + signal, + prompt: strField(event.input, "prompt") ?? "", + cwd: ctx.directory ?? process.cwd(), + }); +``` + +Add a module-level `const subagentTails = new Map<string, { stopped: boolean }>()`, and a helper that snapshots existing transcripts, polls `findSubagentTranscript` until the file appears (giving up after ~30s), then `tailTranscript`s it — converting each `tool` entry into a `running` then `completed` tool part via `live.toolPart`, and ignoring `text` entries (the final answer already arrives through `finalize`). + +- [ ] **Step 4: Stop the tail on both completion paths** + +In the tool-result success and error branches, beside each `unregisterSubagentCall(event.id)`: + +```typescript + const tail = subagentTails.get(event.id); + if (tail) { + tail.stopped = true; + subagentTails.delete(event.id); + } +``` + +- [ ] **Step 5: Run the full suite** + +Run: `npm run typecheck && npx vitest run` +Expected: typecheck clean; all tests pass. + +- [ ] **Step 6: Exercise it** + +Rebuild, restart opencode in this worktree, run a Cursor subagent task, and watch the card. Expected: `↳ Read <path>` style subtitle updating while the subagent runs, and the card navigable throughout. + +- [ ] **Step 7: Commit** + +```bash +git add src/provider/stream-map.ts test/stream-map.test.ts +git commit -m "feat: stream cursor subagent activity into the task card" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** navigation (Task 1), live subtitle (Tasks 2-6), transcript fidelity (already shipped, plus Task 3's parser). +- **Sequencing risk:** Task 1 gates everything. If its Step 3 shows the transcript is written only at completion, Tasks 3-6 must be abandoned rather than adapted. +- **Type consistency:** `toolPart` returns `string | undefined` in Tasks 5 and 6; `upsertToolPart` returns `boolean` in Task 2 — the adapter in Task 5 converts between them. +- **Cleanup:** every tail owns a `signal` that must be flipped on both the success and error result paths, or a polling timer outlives the turn. diff --git a/docs/superpowers/plans/2026-08-21-opencode-subagent-view-source-findings.md b/docs/superpowers/plans/2026-08-21-opencode-subagent-view-source-findings.md new file mode 100644 index 0000000..ef617e6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-opencode-subagent-view-source-findings.md @@ -0,0 +1,85 @@ +# Source findings: native-like Cursor subagent cards without an upstream PR + +Research against the **installed** opencode source, v1.18.18 (sparse clone: +`/tmp/opencode-src`, sparse = packages/opencode/src, packages/tui/src, +packages/plugin/src, packages/sdk). The old checkout at +`~/workspace/opencode` is the 2025 Go-TUI architecture — **do not trust it**; +its task tool used `metadata.summary` and it has no PATCH part endpoint. + +Also researched: `pi-cursor-sdk` + `@cursor/sdk` +(`~/.pi/agent/npm/node_modules/…`) for comparable patterns. + +## Verified mechanism chain (v1.18.18) + +1. **Native task tool stamps the link itself.** `tool/task.ts:167-176` creates + the child session (`parentID: ctx.sessionID`), then `:185-193` calls + `ctx.metadata({ title, metadata: { sessionId: nextSession.id, … } })` + *before* executing. The link exists from the start of the run; there is no + pending/running window to race. +2. **TUI card reads** `metadata.sessionId` (`tui/src/routes/session/index.tsx:2238`), + syncs the child session on mount (`:2235-2238`), and renders the subtitle + from `tool` parts in the child session: `tools()` memo `:2244-2248` (no role + filter), `current()` `:2250-2252` (last part with status running/completed + and a title), subtitle `:2279-2291` = `↳ <Tool> <title>`. +3. **PATCH part is an upsert.** `httpapi/handlers/session.ts:397-412` validates + id/messageID/sessionID match the path and calls `session.updatePart`; + `session.ts:637-646` publishes `SessionV1.Event.PartUpdated` → SSE + `message.part.updated`. The TUI event handler (`tui/src/…/sync.tsx:165,376`) + filters by **directory only** — child-session part updates reach the parent + view live. +4. **`noReply` exists** (`session/prompt.ts:1504` schema, `:1069` skips the LLM + loop) — a plugin can seed a message in a synthesized child session without + invoking a model. +5. **Plugin surface** (`plugin/index.ts`): `event` hook fires for every + directory event (`:257`); plugin gets a full SDK `client` (`:144,158-167`). + +## Key improvement over the current plan: providerMetadata, not PATCH-stamping + +`session/processor.ts:337-356` (tool-call) merges the AI SDK stream part's +`providerMetadata` into the tool part's top-level `metadata` — the exact field +the TUI card reads for `sessionId` (`:249` also sets metadata for +providerExecuted tools). Since this plugin **authors the stream** +(`stream-map.ts`), it can stamp `{ sessionId: childId }` as `providerMetadata` +on the task `tool-call` part inline. That: + +- eliminates the Task-1 race entirely (no event-hook + re-read + PATCH), +- matches how the link is stored natively (same `metadata.sessionId` key), +- works while `pending`→`running` because the processor writes metadata on the + tool-call event itself. + +**Must verify empirically:** whether the processor stores `providerMetadata` +flat or namespaced by provider (`{ cursor: { sessionId } }` would not satisfy +`metadata.sessionId`). Read `processor.ts:337-356` closely and log one real +part. If namespaced, fall back to the plan's PATCH-stamp path. + +## Cursor SDK side (pi-cursor-sdk comparison) + +- Confirmed: `@cursor/sdk` `SendOptions` has only `onDelta`/`onStep` + (`agent.d.ts:31-39`). No `onSubagent`, no nested stream events. Subagent + activity lands only in the task result's `conversationSteps[]` + + `transcriptPath` (both post-completion). +- pi-cursor-sdk does **no** live subagent streaming — it summarizes + `conversationSteps` after completion + (`cursor-tool-result-display-readers.ts:94-100`) and never reads + `transcriptPath`. Our transcript-tail approach goes beyond it; no pattern to + copy, but also no contradiction. +- Reusable detail: task args carry `subagentType: { kind, name }` — good for + display naming of the card. + +## Resulting architecture (addon-only, no upstream PR) + +1. Child session created at task tool-call time (existing bridge code). +2. `sessionId` stamped via `providerMetadata` on the streamed tool-call part + (new; replaces the racy PATCH stamp) — pending empirical check above. +3. Live activity: tail Cursor's on-disk subagent transcript (full-rewrite + semantics, completed-line counting — as planned), upsert `tool` parts into + the child session via the PATCH upsert endpoint. TUI receives the SSE part + updates because filtering is by directory. +4. Final result still flows through the existing finalize path. + +## Open verifications + +- [ ] providerMetadata flat-vs-namespaced (see above) — decide stamp path. +- [ ] Transcript checkpoint cadence mid-run (plan Task 1 Step 3). +- [ ] noReply message shape: confirm which message id (user vs assistant) the + response returns, for anchoring synthesized tool parts. From 0a76af8567f58be7da3acec017a76205c5f595cd Mon Sep 17 00:00:00 2001 From: Justin Carper <justin.carper1@accesscfa.com> Date: Fri, 21 Aug 2026 15:52:24 -0500 Subject: [PATCH 2/2] feat: live Cursor subagent activity in the task card A Cursor subagent's task card now behaves like a native opencode subagent card: clickable while running, with a live activity subtitle. - Child session is created up-front when the task call starts and state.metadata.sessionId is stamped on the RUNNING task part via part.update (the native task tool's execute-time equivalent), so the card is navigable from the start of the run. - Nested subagent activity (taskUpdate payloads on the parent task's tool-call-delta updates) is normalized into subagent-events and materialised as real tool parts in the child session (upserted via part.update): running on tool-start, completed on tool-result, with stragglers completed at finalize. This is what the TUI reads for its live activity subtitle. - The child session is seeded with the subagent's prompt and its rendered activity (text, thinking, tool calls, conversation steps, final answer, duration). - upsertToolPart treats a resolved { error } response as failure (hey-api's request does not throw on 4xx) and always sends state.metadata on completed parts, which the schema requires. - cursor_delegate also links a child session seeded with its transcript. --- CHANGELOG.md | 27 ++ src/plugin/cursor-tools.ts | 22 ++ src/plugin/index.ts | 103 ++++-- src/provider/agent-events.ts | 89 ++++- src/provider/child-parts.ts | 149 +++++++++ src/provider/delegate.ts | 1 + src/provider/stream-map.ts | 186 ++++++++--- src/provider/subagent-bridge.ts | 556 +++++++++++++++++++++++++++++++- src/provider/subagent-stream.ts | 255 +++++++++++++++ test/agent-events.test.ts | 79 +++++ test/child-parts.test.ts | 157 +++++++++ test/cursor-tools.test.ts | 49 +++ test/stream-map.test.ts | 264 ++++++++++++--- test/subagent-bridge.test.ts | 515 +++++++++++++++++++++++++++++ test/subagent-stream.test.ts | 230 +++++++++++++ 15 files changed, 2542 insertions(+), 140 deletions(-) create mode 100644 src/provider/child-parts.ts create mode 100644 src/provider/subagent-stream.ts create mode 100644 test/child-parts.test.ts create mode 100644 test/subagent-bridge.test.ts create mode 100644 test/subagent-stream.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1b6c5..57f30ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Cursor subagent transcripts in the TUI subagent view.** The child session + created for a Cursor subagent (`task` tool) is now seeded with the subagent's + own activity — its assistant text, thinking, and tool calls with args and + results — rendered from Cursor's `conversationSteps`, plus the final answer + and duration. Previously only a post-completion activity summary appeared. + Steps arrive as raw protobuf JSON, where `agent.v1.ConversationStep`'s `message` + oneof serialises to a single camelCase key (`{ assistantMessage: … }`, + `{ toolCall: { shellToolCall: … } }`) rather than the `{ type, message }` shape + of the SDK's public type; both are accepted. Transcript content is never + truncated — the child session carries the subagent's full output. +- **Live activity on the Cursor subagent card.** The SDK streams a local + subagent's nested activity via `taskUpdate` payloads on the parent task's + `tool-call-delta` updates (text, thinking, tool-start/tool-result with + id + name + input). Those events now write real `tool` parts into the child + session via `part.update` (an upsert — `session/processor.ts` creates parts + the same way), so the `task` card shows a live `↳ <Tool> <title>` subtitle + while the subagent runs (the TUI builds that line purely from `tool` parts + in the child session — `tui/routes/session/index.tsx:2227-2279`). + The child session is created up-front when the `task` call starts and the + task card's `state.metadata.sessionId` is stamped while the subagent is still + running (via opencode's `part.update` endpoint, mirroring the native task + tool's execute-time metadata publication), so the card is clickable / + `ctrl+x`-navigable live. Tool calls complete when their tool-result event + arrives; any call left open is completed at finalize. + `cursor_delegate` also creates a child session seeded with its transcript, + discoverable via the TUI's subagent panel. + ## [0.7.1] — 2026-08-05 The skills bridge (#90), per-model context limits and pricing (#89), and the diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index f073631..16606fe 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -1,6 +1,7 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin"; import { runCloudAgent } from "../provider/cloud-agent.js"; import { runDelegate } from "../provider/delegate.js"; +import { linkDelegateSession } from "../provider/subagent-bridge.js"; const s = tool.schema; @@ -207,6 +208,27 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin `${result.toolActivity.some((t) => t.isError) ? ", some failed" : ""})` : ""; + // Surface the delegate's work in a child session so it's discoverable + // in the TUI's subagent panel. Best-effort: a failed link never breaks + // the turn. The result card itself stays a tool block (a custom tool + // can't render a navigable `task` part), so the child session is + // reached via the subagent panel, not by clicking the result. + if (context.sessionID) { + const transcript = [ + result.text || "(no text output)", + ...(result.reasoning ? [`\n> ${result.reasoning}`] : []), + ...(result.toolActivity.length > 0 + ? [`\n(${result.toolActivity.length} tool call(s))`] + : []), + ].join("\n"); + await linkDelegateSession({ + parentSessionID: context.sessionID, + title: `Cursor delegate (${args.model})`, + prompt: args.prompt, + transcript, + }); + } + return { title: `Cursor delegate (${args.model})`, output: (result.text || "(no text output)") + toolNote, diff --git a/src/plugin/index.ts b/src/plugin/index.ts index a1f18d9..c2a5ff4 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -13,9 +13,18 @@ import { translateMcpServers, } from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; -import { getLocalVersion, getLatestVersion, clearVersionCache, PLUGIN_CACHE_PATH } from "../version-check.js"; +import { + getLocalVersion, + getLatestVersion, + clearVersionCache, + PLUGIN_CACHE_PATH, +} from "../version-check.js"; import { removeSystemRule } from "../provider/system-rule.js"; -import { clearLogBridge, pluginLog, setLogBridge } from "../provider/log-bridge.js"; +import { + clearLogBridge, + pluginLog, + setLogBridge, +} from "../provider/log-bridge.js"; import { writeSkillMirror, removeSkillMirror, @@ -29,6 +38,8 @@ import { import { clearSubagentBridge, setSubagentBridge, + subagentCallChildId, + stampTaskPartSessionId, } from "../provider/subagent-bridge.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { @@ -62,17 +73,18 @@ export const CursorPlugin: Plugin = async (input) => { // Surfaces the update notice in the UI (toast). Resolved once per plugin // instance using the shared fetch above. - const _versionCheckPromise: Promise<{ local: string; latest: string } | null> = (async () => { - try { - if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null; - const local = getLocalVersion(); - const latest = await _latestVersionPromise; - if (!local || !latest || !semver.gt(latest, local)) return null; - return { local, latest }; - } catch { - return null; - } - })(); + const _versionCheckPromise: Promise<{ local: string; latest: string } | null> = + (async () => { + try { + if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null; + const local = getLocalVersion(); + const latest = await _latestVersionPromise; + if (!local || !latest || !semver.gt(latest, local)) return null; + return { local, latest }; + } catch { + return null; + } + })(); let _toastShown = false; // The Cursor API key resolved by opencode's auth loader, captured so the @@ -108,7 +120,6 @@ export const CursorPlugin: Plugin = async (input) => { }) .catch(() => {}); - const directory = input?.directory; // Publish the opencode client + directory so the provider stream layer can // create a real child session for each Cursor subagent (making its `task` @@ -180,10 +191,7 @@ export const CursorPlugin: Plugin = async (input) => { const { models } = await discoverModels({}); config.provider ??= {}; const existing = config.provider[PROVIDER_ID] ?? {}; - const existingOptions = (existing.options ?? {}) as Record< - string, - unknown - >; + const existingOptions = (existing.options ?? {}) as Record<string, unknown>; // Forward opencode's configured MCP servers to the Cursor // agent so it can use the same servers. Opt out via @@ -340,10 +348,9 @@ export const CursorPlugin: Plugin = async (input) => { // Cursor agent can't connect. Only those without a shareable // client registration are skipped; ones with a clientId are // forwarded with an `auth` block for the agent's own OAuth flow. - const unshareable = findUnshareableOAuthServers( - liveMcp, - status, - ).filter((name) => !warnedOAuth.has(name)); + const unshareable = findUnshareableOAuthServers(liveMcp, status).filter( + (name) => !warnedOAuth.has(name), + ); if (unshareable.length > 0) { for (const name of unshareable) warnedOAuth.add(name); const plural = unshareable.length > 1; @@ -382,8 +389,7 @@ export const CursorPlugin: Plugin = async (input) => { writeSkillMirror(resolvedCwd, resolved.skills, (msg) => pluginLog("warn", msg), ); - currentSkillsCatalogue = - buildSkillsCatalogue(resolved.skills) ?? ""; + currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? ""; lastSkillHash = hash; } } catch { @@ -396,6 +402,31 @@ export const CursorPlugin: Plugin = async (input) => { } }, + // Stamp the child session id on the RUNNING `task` part. The provider + // creates the child session when the Cursor subagent starts and + // publishes call→child on the bridge registry; when opencode's + // processor lands the task part (`message.part.updated`), patch it + // (`part.update`, the native `ctx.metadata` equivalent) so the TUI + // card carries `state.metadata.sessionId` from the start — matching + // the native task tool, which publishes the id at execute time. The + // processor emits a running-state part update for every streamed + // tool part, so this fires early; the stamp is idempotent. + event: async (input) => { + const evt = input.event; + if (evt.type !== "message.part.updated") return; + const part = evt.properties.part; + if (!part || part.type !== "tool" || part.tool !== "task") return; + const childId = subagentCallChildId(part.callID); + if (!childId) return; + void stampTaskPartSessionId({ + sessionID: part.sessionID, + messageID: part.messageID, + partID: part.id, + part, + childId, + }); + }, + tool: { cursor_update_plugin: { description: @@ -406,7 +437,11 @@ export const CursorPlugin: Plugin = async (input) => { return { title: "cursor plugin (checks disabled)", output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).", - metadata: { local: undefined, latest: undefined, status: "disabled" as const }, + metadata: { + local: undefined, + latest: undefined, + status: "disabled" as const, + }, }; } @@ -423,7 +458,8 @@ export const CursorPlugin: Plugin = async (input) => { if (!latest || !semver.valid(latest)) { return { title: "cursor plugin (registry unavailable)", - output: "Could not fetch the latest version from npm. Check your network connection and try again.", + output: + "Could not fetch the latest version from npm. Check your network connection and try again.", metadata: { local, latest, status: "failed" as const }, }; } @@ -436,11 +472,12 @@ export const CursorPlugin: Plugin = async (input) => { }; } - // Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch. - const cachePath = PLUGIN_CACHE_PATH; - const removeCommand = process.platform === "win32" - ? `rmdir /s /q "${cachePath}"` - : `rm -rf ${cachePath}`; + // Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch. + const cachePath = PLUGIN_CACHE_PATH; + const removeCommand = + process.platform === "win32" + ? `rmdir /s /q "${cachePath}"` + : `rm -rf ${cachePath}`; try { rmSync(cachePath, { recursive: true, force: true }); @@ -472,9 +509,7 @@ export const CursorPlugin: Plugin = async (input) => { args: {}, execute: async () => { const result = await discoverModels({ forceRefresh: true }); - const lines = result.models.map( - (m) => `- ${m.id} — ${m.displayName}`, - ); + const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`); const header = result.source === "live" ? `Refreshed ${result.models.length} Cursor models (live):` diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index e6d9f05..06996c3 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -11,6 +11,18 @@ export interface CursorUsage { cacheWriteTokens: number; } +/** + * A single nested update streamed from a Cursor subagent (the `task` tool). + * The SDK surfaces these via the `tool-call-delta` interaction update; we + * normalize the nested `taskUpdate` union into this small shape so the stream + * layer can render it without depending on SDK internals. + */ +export type SubagentNestedEvent = + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { type: "tool-start"; id: string; name: string; input: unknown } + | { type: "tool-result"; id: string; name: string; result: unknown; isError: boolean }; + /** Normalized events bridged from the Cursor SDK's push callbacks. */ export type CursorEvent = | { type: "text-delta"; text: string } @@ -21,7 +33,13 @@ export type CursorEvent = | { type: "usage"; usage: CursorUsage } | { type: "reasoning-complete"; durationMs?: number } | { type: "compaction" } - | { type: "finish"; text?: string }; + | { type: "finish"; text?: string } + /** + * A nested update from a Cursor subagent. `callId` is the parent's `task` + * tool-call id, so the stream layer can route the event to the right child + * session. Only one level of nesting is surfaced by the SDK. + */ + | { type: "subagent-event"; callId: string; event: SubagentNestedEvent }; export interface StreamAgentTurnOptions { mode: AgentModeOption; @@ -36,6 +54,10 @@ export interface StreamAgentTurnOptions { usageBase?: CursorUsage; } +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null; +} + /** Sum two usage reports (either may be absent). */ export function addUsage(a?: CursorUsage, b?: CursorUsage): CursorUsage | undefined { if (!a) return b; @@ -65,6 +87,56 @@ function toolDisplayName(toolCall: ({ type?: string } & Record<string, any>) | u return toolCall.type ?? "tool"; } +/** + * Normalize a nested subagent `taskUpdate` (from the SDK's `tool-call-delta` + * interaction update) into a {@link SubagentNestedEvent}, or `undefined` when + * the update carries nothing the stream layer renders (partials, step + * bookkeeping, thinking-completed). The nested union is read defensively — + * the SDK types are the contract, but the shape is opaque at runtime. + */ +function normalizeNestedTaskUpdate(update: unknown): SubagentNestedEvent | undefined { + if (!isRecord(update)) return undefined; + switch (update["type"]) { + case "text-delta": + return typeof update["text"] === "string" + ? { type: "text", text: update["text"] } + : undefined; + case "thinking-delta": + return typeof update["text"] === "string" + ? { type: "reasoning", text: update["text"] } + : undefined; + case "tool-call-started": { + const toolCall = isRecord(update["toolCall"]) ? update["toolCall"] : undefined; + const id = typeof update["callId"] === "string" ? update["callId"] : ""; + return { + type: "tool-start", + id, + name: toolDisplayName(toolCall), + input: toolCall?.args ?? {}, + }; + } + case "tool-call-completed": { + const toolCall = isRecord(update["toolCall"]) ? update["toolCall"] : undefined; + const id = typeof update["callId"] === "string" ? update["callId"] : ""; + const result = toolCall?.result; + const resultValue = isRecord(result) ? result["value"] : undefined; + const mcpError = + toolCall?.type === "mcp" && isRecord(resultValue) && resultValue["isError"] === true; + return { + type: "tool-result", + id, + name: toolDisplayName(toolCall), + result: result ?? null, + isError: (isRecord(result) && result["status"] === "error") || mcpError, + }; + } + default: + // partial-tool-call, thinking-completed, step-started, step-completed: + // nothing to render (the final tool-call carries full args). + return undefined; + } +} + /** * Node stores a timer delay in a signed 32-bit int; anything larger overflows * and is silently clamped to `1`. An operator following the tool-phase stall @@ -203,6 +275,21 @@ export async function* streamAgentTurn( }); break; } + case "tool-call-delta": { + // Nested updates from a Cursor subagent (the `task` tool). `callId` is + // the parent task tool-call id; the stream layer routes the event to + // the matching child session. Ignored when the nested update carries + // nothing renderable. + const nested = normalizeNestedTaskUpdate(update.taskUpdate); + if (nested) { + push({ + type: "subagent-event", + callId: String(update.callId), + event: nested, + }); + } + break; + } case "turn-ended": // Reconcile: a dropped or differently-keyed `tool-call-completed` // would otherwise leave an entry pinned here, holding the turn on the diff --git a/src/provider/child-parts.ts b/src/provider/child-parts.ts new file mode 100644 index 0000000..08ed468 --- /dev/null +++ b/src/provider/child-parts.ts @@ -0,0 +1,149 @@ +import { randomBytes } from "node:crypto"; +import { pluginLog } from "./log-bridge.js"; +import { getSubagentBridge } from "./subagent-bridge.js"; + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const RANDOM_LENGTH = 14; + +let lastTimestamp = 0; +let counter = 0; + +/** + * Generate an opencode-compatible ascending part id. + * + * Mirrors `packages/opencode/src/id/id.ts:51`: a 6-byte big-endian + * `timestamp * 0x1000 + counter` rendered as hex, followed by random base62. + * The counter keeps ids monotonic (and unique) within a millisecond, which is + * what makes parts sort correctly in the TUI. + */ +export function createPartID(now?: number): string { + const timestamp = now ?? Date.now(); + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp; + counter = 0; + } + counter++; + const value = BigInt(timestamp) * BigInt(0x1000) + BigInt(counter); + const bytes = Buffer.alloc(6); + for (let i = 0; i < 6; i++) { + bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff)); + } + let random = ""; + const raw = randomBytes(RANDOM_LENGTH); + for (let i = 0; i < RANDOM_LENGTH; i++) random += BASE62[raw[i]! % 62]; + return `prt_${bytes.toString("hex")}${random}`; +} + +const PART_URL = "/session/{sessionID}/message/{messageID}/part/{partID}"; + +/** Arguments describing one tool call to materialise in a child session. */ +export interface ToolPartInput { + sessionID: string; + messageID: string; + partID: string; + callID: string; + tool: string; + status: "running" | "completed"; + title?: string; + input?: unknown; + output?: string; + start: number; + end?: number; +} + +/** + * Create or update a `tool` part inside a subagent's child session. + * + * `updatePart` is an upsert — opencode's own processor creates parts through it + * (`session/processor.ts:242`) — so a fresh `partID` inserts and a repeated one + * updates. This is the only way to surface Cursor subagent activity in the TUI: + * the task card's activity subtitle is built purely from `type === "tool"` + * parts found in the child session (`tui/routes/session/index.tsx:2227-2279`). + * + * The published v1 `OpencodeClient` doesn't expose `part.update`, so the + * underlying hey-api client is reached through its runtime `_client` field. + * + * Best-effort — never throws, so a failed write cannot break the parent turn — + * but always logs, because a silent failure here is indistinguishable from + * "the subagent did nothing". + */ +export async function upsertToolPart(opts: ToolPartInput): Promise<boolean> { + const bridge = getSubagentBridge(); + // SAFETY: the published v1 OpencodeClient type hides the hey-api runtime + // client; `_client.request` exists at runtime (optional-chained below) + // even though it is absent from the public types. + const request = ( + bridge?.client as unknown as + | { + _client?: { + request?: (options: Record<string, unknown>) => Promise<unknown>; + }; + } + | undefined + )?._client?.request; + if (!bridge || !request) { + pluginLog("debug", "subagent: tool part skipped", { + reason: "no bridge", + tool: opts.tool, + }); + return false; + } + const state: Record<string, unknown> = { + status: opts.status, + input: opts.input ?? {}, + time: + opts.status === "completed" + ? { start: opts.start, end: opts.end ?? Date.now() } + : { start: opts.start }, + }; + if (opts.title) state["title"] = opts.title; + if (opts.status === "completed") { + state["output"] = opts.output ?? ""; + // The completed ToolState schema requires `metadata` (the running state + // omits it). Missing it earns a 400: "Missing key at [state][metadata]". + state["metadata"] = {}; + } + try { + const res = await request({ + method: "PATCH", + url: PART_URL, + path: { + sessionID: opts.sessionID, + messageID: opts.messageID, + partID: opts.partID, + }, + ...(bridge.directory ? { query: { directory: bridge.directory } } : {}), + body: { + id: opts.partID, + messageID: opts.messageID, + sessionID: opts.sessionID, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }); + // hey-api's runtime `request` RESOLVES `{ error }` on a 4xx instead of + // rejecting, so a rejected payload looks like success unless checked. + if ( + typeof res === "object" && + res !== null && + "error" in res && + (res as { error: unknown }).error != null + ) { + pluginLog("warn", "subagent: tool part upsert rejected", { + tool: opts.tool, + status: opts.status, + error: JSON.stringify((res as { error: unknown }).error).slice(0, 300), + }); + return false; + } + return true; + } catch (err) { + pluginLog("warn", "subagent: tool part upsert failed", { + tool: opts.tool, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts index 7236499..dd195aa 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -116,6 +116,7 @@ export async function runDelegate( break; case "reasoning-complete": case "compaction": + case "subagent-event": break; case "finish": // The aggregated result text; prefer it when deltas were absent. diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 59a847c..20ccc27 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -6,7 +6,14 @@ import type { LanguageModelV3Usage, } from "@ai-sdk/provider"; import type { CursorEvent, CursorUsage } from "./agent-events.js"; -import { linkSubagentSession } from "./subagent-bridge.js"; +import { + activityLine, + linkSubagentSession, + linkSubagentSessionLive, + registerSubagentCall, + unregisterSubagentCall, +} from "./subagent-bridge.js"; +import { SubagentTranscriptSink } from "./subagent-stream.js"; /** Per-turn context threaded from the language model into the stream mapper. */ export interface StreamContext { @@ -202,11 +209,23 @@ function numField(v: unknown, key: string): number | undefined { : undefined; } +/** Any JSON-deserializable value; Cursor tool results are arbitrary JSON. */ +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + /** Unwrap a Cursor `{ status:"success", value }` result to its `value`. */ -function successValue(result: unknown): unknown { - return isRecord(result) && result["status"] === "success" - ? result["value"] - : undefined; +function successValue(result: unknown): JsonValue | undefined { + if (!isRecord(result) || result["status"] !== "success") return undefined; + const value = result["value"]; + if (value === undefined) return undefined; + // SAFETY: `value` crossed a JSON-deserialization boundary inside the Cursor + // SDK, so it can only contain JSON values; the cast narrows, never invents. + return value as JsonValue; } /** @@ -283,8 +302,8 @@ function mcpFold(result: unknown): FoldedResult | null { } /** Cursor MCP call args nest the real tool input under `args.args`. */ -function mcpInputArgs(args: unknown): unknown { - return isRecord(args) ? args["args"] : undefined; +function mcpInputArgs(args: unknown): Record<string, unknown> | undefined { + return isRecord(args) && isRecord(args["args"]) ? args["args"] : undefined; } /** Map a Cursor MCP `providerIdentifier` to a websearch provider label key. */ @@ -309,7 +328,7 @@ const WEBSEARCH_ADAPTER: NativeToolAdapter = { tool: "websearch", input: (args) => { const query = strField(mcpInputArgs(args), "query"); - return query !== undefined ? { query } : {}; + return query === undefined ? {} : { query }; }, result: (value, args) => { const provider = webSearchProvider(args); @@ -411,17 +430,17 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { const fileSize = numField(value, "fileSize"); const linesReturned = content.split("\n").length; const lineLabel = - totalLines !== undefined - ? `${linesReturned}/${totalLines} lines` - : `${linesReturned} lines`; + totalLines === undefined + ? `${linesReturned} lines` + : `${linesReturned}/${totalLines} lines`; return { title: `${filePath} (${lineLabel})`, metadata: { preview: content.split("\n").slice(0, 20).join("\n"), loaded: [] as string[], linesReturned, - ...(totalLines !== undefined ? { totalLines } : {}), - ...(fileSize !== undefined ? { fileSize } : {}), + ...(totalLines === undefined ? {} : { totalLines }), + ...(fileSize === undefined ? {} : { fileSize }), }, output: content, }; @@ -438,9 +457,9 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { const filePath = strField(args, "path") ?? ""; const lines = numField(value, "linesCreated"); const output = - lines !== undefined - ? `Wrote ${lines} line${lines === 1 ? "" : "s"}.` - : "Wrote file successfully."; + lines === undefined + ? "Wrote file successfully." + : `Wrote ${lines} line${lines === 1 ? "" : "s"}.`; return { title: filePath, metadata: { diagnostics: {}, filepath: filePath, exists: false }, @@ -513,9 +532,7 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { current = file; lines.push(`${file}:`); } - lines.push( - line !== undefined ? ` Line ${line}: ${text}` : ` ${text}`, - ); + lines.push(line === undefined ? ` ${text}` : ` Line ${line}: ${text}`); total++; } } else if ( @@ -542,11 +559,9 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { metadata: { matches: total, truncated: false }, output: total > 0 - ? [ - `Found ${total} match${total === 1 ? "" : "es"}`, - "", - ...lines, - ].join("\n") + ? [`Found ${total} match${total === 1 ? "" : "es"}`, "", ...lines].join( + "\n", + ) : "No matches found", }; }, @@ -555,15 +570,22 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { // + results body instead of the raw `{results}` JSON. semSearch: { input: (args) => { - const out: Record<string, unknown> = { query: strField(args, "query") ?? "" }; - const dirs = isRecord(args) && Array.isArray(args["targetDirectories"]) ? args["targetDirectories"] : undefined; + const out: Record<string, unknown> = { + query: strField(args, "query") ?? "", + }; + const dirs = + isRecord(args) && Array.isArray(args["targetDirectories"]) + ? args["targetDirectories"] + : undefined; if (dirs && dirs.length > 0) out["targetDirectories"] = dirs; return out; }, result: (value, args) => { const query = strField(args, "query") ?? ""; const results = strField(value, "results") ?? ""; - const count = results ? results.split("\n").filter((l) => l.trim().length > 0).length : 0; + const count = results + ? results.split("\n").filter((l) => l.trim().length > 0).length + : 0; return { title: query, metadata: { matches: count, truncated: false }, @@ -671,18 +693,16 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { const line = numField(start, "line"); const char = numField(start, "character"); const loc = - line !== undefined - ? ` L${line + 1}${char !== undefined ? `:${char + 1}` : ""}` - : ""; + line === undefined + ? "" + : ` L${line + 1}${char === undefined ? "" : `:${char + 1}`}`; lines.push(` ${severity}${loc}: ${strField(d, "message") ?? ""}`); total++; } } return { title: - total > 0 - ? `${total} problem${total === 1 ? "" : "s"}` - : "No problems", + total > 0 ? `${total} problem${total === 1 ? "" : "s"}` : "No problems", metadata: { count: total }, output: total > 0 ? lines.join("\n") : "No problems found.", }; @@ -699,9 +719,9 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = { title: path, metadata: {}, output: - size !== undefined - ? `Deleted ${path} (${size} bytes).` - : `Deleted ${path}.`, + size === undefined + ? `Deleted ${path}.` + : `Deleted ${path} (${size} bytes).`, }; }, }, @@ -901,7 +921,8 @@ function blockToolInputPartialParts( prev && serialized.startsWith(prev.serialized) ? serialized.slice(prev.serialized.length) : serialized; - if (delta) parts.push({ type: "tool-input-delta", id, delta } as BlockToolPart); + if (delta) + parts.push({ type: "tool-input-delta", id, delta } as BlockToolPart); return parts; } @@ -1107,6 +1128,10 @@ export function cursorEventsToStream( let compactions = 0; // Blocks-mode tool bookkeeping (open non-edit calls + buffered edits). const toolState = newBlockToolState(); + // Live subagent sinks keyed by the parent `task` tool-call id. A sink + // exists only when a child session was created up-front (bridge present + // + parent sessionID); otherwise the post-completion link is used. + const subagentSinks = new Map<string, SubagentTranscriptSink>(); const closeDanglingToolCalls = () => { for (const part of blockDanglingParts(toolState)) { controller.enqueue(part); @@ -1214,6 +1239,33 @@ export function cursorEventsToStream( toolState.dropped.add(event.id); break; } + // A Cursor subagent is starting. Create the child session + // up-front so its nested activity can stream into the TUI + // subagent view live, and register the call→child mapping + // so the plugin's event hook can stamp the RUNNING task + // part's `state.metadata.sessionId` (the native task tool + // publishes it at execute time via `session.updatePart`; + // provider-side metadata channels can't reach + // `state.metadata`). Blocks mode only (a reasoning-mode + // turn renders no task card to stamp the child id on). + // Best-effort: on failure the sink is absent and the + // post-completion link (tool-result path) degrades to the + // previous behavior. + if ( + toolDisplay === "blocks" && + event.name === TASK_TOOL_NAME && + !subagentSinks.has(event.id) && + ctx.sessionID + ) { + const live = await linkSubagentSessionLive({ + parentSessionID: ctx.sessionID, + args: event.input, + }); + if (live) { + subagentSinks.set(event.id, new SubagentTranscriptSink(live)); + registerSubagentCall(event.id, live.childId); + } + } if (toolDisplay === "blocks") { if (toolState.partials.delete(event.id)) { controller.enqueue({ @@ -1236,9 +1288,7 @@ export function cursorEventsToStream( closeText(); closeReasoning(); } - for (const part of parts) { - controller.enqueue(part); - } + for (const part of parts) controller.enqueue(part); } else { reasoningLine(`\n${formatToolCall(event.name, event.input)}\n`); } @@ -1269,17 +1319,39 @@ export function cursorEventsToStream( // card at it so it's clickable / ctrl+x-navigable. Best-effort: // linkSubagentSession swallows all failures and returns // undefined, leaving the card exactly as before. - if ( - event.name === TASK_TOOL_NAME && - !event.isError && - ctx.sessionID - ) { - const childId = await linkSubagentSession({ - parentSessionID: ctx.sessionID, - args: taskArgs, - result: event.result, - }); - if (childId) injectSubagentSessionId(parts, childId); + if (event.name === TASK_TOOL_NAME && !event.isError) { + const sink = subagentSinks.get(event.id); + if (sink) { + // Live path: the child session already exists and has + // been streaming nested activity. Finalize it (flush any + // remaining content + the subagent's final answer + its + // conversation steps + the activity line) and stamp the + // card with the child id. + const value = + isRecord(event.result) && event.result["status"] === "success" + ? event.result["value"] + : undefined; + await sink.finalize(value, activityLine(value)); + injectSubagentSessionId(parts, sink.childId); + subagentSinks.delete(event.id); + unregisterSubagentCall(event.id); + } else if (ctx.sessionID) { + // No live sink (no bridge / creation failed / background + // task): fall back to the post-completion link so the + // card is still navigable. + const childId = await linkSubagentSession({ + parentSessionID: ctx.sessionID, + args: taskArgs, + result: event.result, + }); + if (childId) injectSubagentSessionId(parts, childId); + } + } else if (event.name === TASK_TOOL_NAME) { + // The task errored (or a dropped task): release any + // pending call→child mapping so the plugin's event hook + // can't stamp a later part that reuses this call id. + subagentSinks.delete(event.id); + unregisterSubagentCall(event.id); } for (const part of parts) { controller.enqueue(part); @@ -1288,13 +1360,18 @@ export function cursorEventsToStream( reasoningLine(`[tool] ${event.name} failed\n`); } break; + case "subagent-event": { + // Route a nested subagent update to the matching live sink. + const sink = subagentSinks.get(event.callId); + if (sink) sink.push(event.event); + break; + } case "usage": usage = mapUsage(event.usage); break; case "reasoning-complete": closeReasoning(); - if (typeof event.durationMs === "number") - thinkingMs += event.durationMs; + if (typeof event.durationMs === "number") thinkingMs += event.durationMs; break; case "compaction": compactions++; @@ -1433,6 +1510,9 @@ export async function cursorEventsToContent( break; case "compaction": break; + case "subagent-event": + // Non-streaming path: no live subagent activity to surface. + break; case "finish": if (!text && event.text) text = event.text; break; diff --git a/src/provider/subagent-bridge.ts b/src/provider/subagent-bridge.ts index 0c32109..c6af988 100644 --- a/src/provider/subagent-bridge.ts +++ b/src/provider/subagent-bridge.ts @@ -1,4 +1,6 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; +import { createPartID, upsertToolPart } from "./child-parts.js"; +import { pluginLog } from "./log-bridge.js"; /** * Bridge from the opencode plugin to the provider stream layer. @@ -9,6 +11,16 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; * opencode child session (`Session.parentID`) and point the task part's * `state.metadata.sessionId` at it. * + * Two paths: + * - Live ({@link linkSubagentSessionLive}): the child session is created when + * the `task` call starts and the subagent's nested activity (text, + * reasoning, tool calls) is flushed into it as it arrives, making the TUI + * subagent view live. Used by the streaming path. + * - Post-completion ({@link linkSubagentSession}): the child session is + * created at `tool-result` time and seeded with the prompt + a rendered + * transcript. Used by the non-streaming path and as a fallback when no live + * session could be created. + * * The provider stream code ({@link cursorEventsToStream}) has no opencode * client. The plugin does (`PluginInput.client` + `directory`). They run in the * same process, so the plugin publishes them here on a `globalThis` registry @@ -42,6 +54,161 @@ export function getSubagentBridge(): SubagentBridge | undefined { return (globalThis as BridgeHolder)[BRIDGE_KEY]; } +// --------------------------------------------------------------------------- +// Running-task registry +// +// A parent `task` tool-call id → child session id map, shared with the plugin +// the same way the bridge client is (globalThis registry). The plugin's event +// hook uses it to stamp the RUNNING task part's `state.metadata.sessionId` via +// the `part.update` endpoint — the native `task` tool publishes the child id +// at execute time through `session.updatePart`, and provider-side metadata +// channels (providerMetadata on V3 tool-call parts) cannot reach +// `state.metadata`, which is what the TUI card reads. +// --------------------------------------------------------------------------- + +const CALL_REGISTRY_KEY = Symbol.for( + "@stablekernel/opencode-cursor:subagent-calls", +); + +type CallRegistry = Map<string, string>; + +function callRegistry(): CallRegistry { + const holder = globalThis as { [CALL_REGISTRY_KEY]?: CallRegistry }; + if (!holder[CALL_REGISTRY_KEY]) holder[CALL_REGISTRY_KEY] = new Map(); + return holder[CALL_REGISTRY_KEY]!; +} + +/** Map a parent `task` tool-call id to the child session id created for it. */ +export function registerSubagentCall(callId: string, childId: string): void { + callRegistry().set(callId, childId); +} + +/** + * Drop the mapping once the task completes. Also invoked after a successful + * stamp so the plugin doesn't re-stamp the same part. + */ +export function unregisterSubagentCall(callId: string): void { + callRegistry().delete(callId); +} + +/** Resolve the child session id for a parent `task` call, if registered. */ +export function subagentCallChildId(callId: string): string | undefined { + return callRegistry().get(callId); +} + +/** + * Stamp `state.metadata.sessionId` on a RUNNING task part via opencode's + * `part.update` HTTP endpoint (PATCH /session/:sid/message/:mid/part/:pid) — + * the exact equivalent of the native task tool's execute-time + * `ctx.metadata({ metadata: { sessionId } })`. The `part` payload is the + * current stored part echoed back with the metadata merged in (the endpoint + * requires id/messageID/sessionID to match the path). Best-effort: failures + * are swallowed so a broken link never affects the turn. + * + * The event's part snapshot may be stale by the time we act (the processor + * streams running-state updates and can complete the part between events), so + * the part is re-read from the message before the PATCH and the stamp is + * skipped if it is no longer `running` — a full-replacement PATCH must never + * clobber a completed/error state. + * + * The published v1 `OpencodeClient` doesn't expose the raw request surface + * (`part.update` only exists on the v2 HttpApi), so the underlying hey-api + * client is reached through its runtime `_client` field. + */ +export async function stampTaskPartSessionId(opts: { + sessionID: string; + messageID: string; + partID: string; + part: unknown; + childId: string; +}): Promise<void> { + const bridge = getSubagentBridge(); + if (!bridge) return skipStamp("no bridge", opts.childId); + if (!isRecord(opts.part)) return skipStamp("part not a record", opts.childId); + const state = isRecord(opts.part["state"]) ? opts.part["state"] : undefined; + if (!state || state["status"] !== "running") { + return skipStamp(`event state ${String(state?.["status"])}`, opts.childId); + } + const metadata = isRecord(state["metadata"]) ? { ...state["metadata"] } : {}; + // Already stamped (a previous part event for the same call): skip so a + // stream of running-state part updates doesn't re-PATCH the same part. + if (metadata["sessionId"] === opts.childId) return; + metadata["sessionId"] = opts.childId; + // SAFETY: the published v1 OpencodeClient type hides the hey-api runtime + // client; the `_client.request` field exists at runtime (checked below via + // optional chaining) even though it is absent from the public types. + const rawClient = ( + bridge.client as unknown as { + _client?: { + request?: (options: { + method: string; + url: string; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + body?: unknown; + }) => Promise<unknown>; + }; + } + )._client; + if (!rawClient?.request) + return skipStamp("client has no request()", opts.childId); + try { + // Re-read the part so the PATCH payload reflects the CURRENT state, not + // the (possibly stale) event snapshot. + const msgRes = await bridge.client.session.message({ + path: { id: opts.sessionID, messageID: opts.messageID }, + ...(bridge.directory ? { query: { directory: bridge.directory } } : {}), + }); + const current = ( + msgRes?.data as { parts?: unknown[] } | undefined + )?.parts?.find((p) => isRecord(p) && p["id"] === opts.partID); + if (!isRecord(current)) + return skipStamp("part not found on re-read", opts.childId); + const currentState = isRecord(current["state"]) + ? current["state"] + : undefined; + if (!currentState || currentState["status"] !== "running") { + return skipStamp( + `re-read state ${String(currentState?.["status"])}`, + opts.childId, + ); + } + const currentMetadata = isRecord(currentState["metadata"]) + ? { ...currentState["metadata"] } + : {}; + currentMetadata["sessionId"] = opts.childId; + await rawClient.request({ + method: "PATCH", + url: "/session/{sessionID}/message/{messageID}/part/{partID}", + path: { + sessionID: opts.sessionID, + messageID: opts.messageID, + partID: opts.partID, + }, + ...(bridge.directory ? { query: { directory: bridge.directory } } : {}), + body: { ...current, state: { ...currentState, metadata: currentMetadata } }, + }); + pluginLog("debug", "subagent: stamped task part", { + childId: opts.childId, + partID: opts.partID, + }); + } catch (err) { + // Best-effort: never let a failed stamp break the turn, but say so — + // a silent no-op here is indistinguishable from "the card just isn't + // clickable", which is exactly the failure this logging exists for. + pluginLog("warn", "subagent: task part stamp failed", { + childId: opts.childId, + partID: opts.partID, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** Record why a live stamp was skipped (see {@link stampTaskPartSessionId}). */ +function skipStamp(reason: string, childId: string): void { + pluginLog("debug", "subagent: task part stamp skipped", { reason, childId }); +} + function isRecord(v: unknown): v is Record<string, unknown> { return typeof v === "object" && v !== null; } @@ -73,7 +240,7 @@ function formatDuration(ms: number): string { * count (an honest proxy) rather than claiming an exact tool-call count. * Returns `undefined` when neither timing nor steps are available. */ -function activityLine(value: unknown): string | undefined { +export function activityLine(value: unknown): string | undefined { const durationMs = numField(value, "durationMs"); const steps = isRecord(value) && Array.isArray(value["conversationSteps"]) @@ -81,7 +248,8 @@ function activityLine(value: unknown): string | undefined { : undefined; const bits: string[] = []; if (steps && steps > 0) bits.push(`${steps} step${steps === 1 ? "" : "s"}`); - if (typeof durationMs === "number") bits.push(`in ${formatDuration(durationMs)}`); + if (typeof durationMs === "number") + bits.push(`in ${formatDuration(durationMs)}`); return bits.length > 0 ? `_Subagent ran ${bits.join(" ")}._` : undefined; } @@ -105,26 +273,194 @@ export function subagentLabel(args: unknown): string { return "general"; } +/** + * Render a Cursor conversation step (from the task result's `conversationSteps`) + * into a readable markdown line. Steps are the subagent's own activity: + * assistant text, thinking, and tool calls with args + results. Returns + * `undefined` for steps that carry nothing renderable. + */ +function renderStep(step: unknown): string | undefined { + if (!isRecord(step)) return undefined; + const norm = normalizeStep(step); + if (!norm) return dumpStep(step); + switch (norm.kind) { + case "assistantMessage": { + const text = strField(norm.payload, "text"); + return text ? text : undefined; + } + case "thinkingMessage": { + const text = strField(norm.payload, "text"); + return text ? `> ${text}` : undefined; + } + case "toolCall": { + const { name, args, result } = toolCallInfo(norm.payload); + let arg = ""; + try { + const s = typeof args === "string" ? args : JSON.stringify(args); + if (s && s !== "{}" && s !== '""') arg = ` ${s}`; + } catch { + // Non-serializable args; show the name only. + } + const head = `**\`${name}\`**${arg}`; + const out = resultText(result); + return out ? `${head}\n\n\`\`\`\n${out}\n\`\`\`` : head; + } + default: + return dumpStep(step); + } +} + +/** + * Last-resort rendering for a step whose shape we don't recognise. Cursor's + * `conversationSteps` is typed as `unknown[]` and has already changed shape + * between SDK representations, so dropping unmatched steps silently turns a + * decoding bug into an empty transcript with no signal. Dumping the raw step + * keeps the subagent's work visible and makes the mismatch self-evident. + */ +function dumpStep(step: unknown): string | undefined { + try { + const s = JSON.stringify(step); + return s && s !== "{}" ? `\`\`\`json\n${s}\n\`\`\`` : undefined; + } catch { + return undefined; + } +} + +/** The three `message` oneof members of `agent.v1.ConversationStep`. */ +const STEP_KINDS = ["assistantMessage", "toolCall", "thinkingMessage"] as const; + +/** + * Reduce a conversation step to `{ kind, payload }` across both shapes Cursor + * can hand us. The task result carries raw protobuf-es `toJson()` output, where + * a oneof serializes to a single camelCase key (`{ assistantMessage: {...} }`); + * the SDK's public zod type instead uses `{ type, message }`. Returns + * `undefined` when the step matches neither. + */ +function normalizeStep( + step: Record<string, unknown>, +): { kind: string; payload: unknown } | undefined { + const selected = oneofMember(step["message"]); + if (selected) return { kind: selected.kind, payload: selected.value }; + const type = strField(step, "type"); + if (type) return { kind: type, payload: step["message"] }; + for (const kind of STEP_KINDS) { + if (kind in step) return { kind, payload: step[kind] }; + } + return undefined; +} + +/** + * Unwrap protobuf-es's runtime representation of a selected oneof member, + * `{ case, value }`. The SDK hands us steps as `toJson()` output only when that + * method exists (`e.toJson?.() ?? e`), so live `Message` objects reach us in + * this form instead. Returns `undefined` for anything else. + */ +function oneofMember( + container: unknown, +): { kind: string; value: unknown } | undefined { + if (!isRecord(container)) return undefined; + const kind = strField(container, "case"); + return kind ? { kind, value: container["value"] } : undefined; +} + +/** Proto suffix on every `agent.v1.ToolCall` oneof member (e.g. `shellToolCall`). */ +const TOOL_CALL_SUFFIX = "ToolCall"; + +/** + * Resolve a tool's display name and args. `agent.v1.ToolCall` is itself a + * oneof, so the proto JSON nests as `{ shellToolCall: { args, result } }`; the + * zod shape flattens to `{ type, args, result }`. + */ +function toolCallInfo(payload: unknown): { + name: string; + args: unknown; + result: unknown; +} { + const rec = isRecord(payload) ? payload : undefined; + // `agent.v1.ToolCall` is itself a oneof, so it nests the same three ways. + const selected = oneofMember(rec?.["tool"]); + if (selected) + return { ...toolName(selected.kind), ...toolFields(selected.value) }; + const type = strField(rec, "type"); + if (type) return { name: type, args: rec?.["args"], result: rec?.["result"] }; + for (const [key, value] of Object.entries(rec ?? {})) { + if (!key.endsWith(TOOL_CALL_SUFFIX)) continue; + return { ...toolName(key), ...toolFields(value) }; + } + return { name: "tool", args: undefined, result: undefined }; +} + +/** Strip the proto `ToolCall` suffix for display (`shellToolCall` → `shell`). */ +function toolName(key: string): { name: string } { + return { + name: key.endsWith(TOOL_CALL_SUFFIX) + ? key.slice(0, -TOOL_CALL_SUFFIX.length) + : key, + }; +} + +/** Pull the `args`/`result` pair every `*ToolCall` message carries. */ +function toolFields(value: unknown): { args: unknown; result: unknown } { + const rec = isRecord(value) ? value : undefined; + return { args: rec?.["args"], result: rec?.["result"] }; +} + +/** + * Extract readable text from a Cursor tool result. Covers the proto shapes + * (`{ stdout }`, `{ content }`) and the SDK's status/value union, falling back + * to JSON. Output is never truncated: the child session is where the full + * subagent transcript lives. + */ +export function resultText(result: unknown): string { + if (typeof result === "string") return result; + if (!isRecord(result)) return ""; + if (typeof result["stdout"] === "string" && result["stdout"]) + return result["stdout"]; + if (typeof result["content"] === "string" && result["content"]) + return result["content"]; + if (result["status"] === "success" && typeof result["value"] === "string") + return result["value"]; + const value = result["value"]; + if (isRecord(value)) { + if (typeof value["stdout"] === "string") return value["stdout"]; + if (typeof value["fileContentAfterWrite"] === "string") + return value["fileContentAfterWrite"]; + } + try { + const s = JSON.stringify(result); + return s && s !== "{}" ? s : ""; + } catch { + return ""; + } +} + +/** + * Render the subagent's `conversationSteps` (its own assistant text, thinking, + * and tool calls) into a readable markdown transcript. Returns `undefined` + * when there are no renderable steps. + */ +export function renderConversationSteps(value: unknown): string | undefined { + if (!isRecord(value) || !Array.isArray(value["conversationSteps"])) + return undefined; + const rendered = (value["conversationSteps"] as unknown[]) + .map(renderStep) + .filter((s): s is string => Boolean(s)); + return rendered.length > 0 ? rendered.join("\n\n") : undefined; +} + /** * Build the child-session transcript body from Cursor's task result `value`. - * Prefers the model-authored `resultSuffix`; appends a compact render of - * `conversationSteps` when present. Returns `undefined` when there's nothing - * useful to post (the prompt message alone still makes the session readable). + * Prefers the model-authored `resultSuffix`; appends a render of + * `conversationSteps` (the subagent's own text/thinking/tool activity) when + * present. Returns `undefined` when there's nothing useful to post (the prompt + * message alone still makes the session readable). */ function buildTranscript(value: unknown): string | undefined { const parts: string[] = []; const suffix = strField(value, "resultSuffix"); if (suffix) parts.push(suffix); - if (isRecord(value) && Array.isArray(value["conversationSteps"])) { - const steps = value["conversationSteps"] as unknown[]; - const rendered = steps - .flatMap((s) => { - const text = strField(s, "text") ?? strField(s, "content"); - return text ? [text] : []; - }) - .join("\n\n"); - if (rendered) parts.push(rendered); - } + const steps = renderConversationSteps(value); + if (steps) parts.push(steps); // Cursor's real timing/activity, surfaced where it's guaranteed visible: the // child session you navigate into (the collapsed one-liner is rendered by // opencode from child-session messages we can't synthesize). @@ -190,3 +526,193 @@ export async function linkSubagentSession(opts: { return undefined; } } + +/** + * A live handle to a child session created for a Cursor subagent. The child + * session is created up-front (when the `task` call starts) so the stream layer + * can flush the subagent's nested activity into it as it arrives, making the + * TUI subagent view live. All methods are best-effort: a failure is swallowed + * and the handle degrades to a no-op so a broken link never breaks the turn. + */ +export interface SubagentLiveSession { + /** The created child session id. */ + childId: string; + /** + * Id of the seeded prompt message. Parts must hang off a real message row + * (the `part` table has a foreign key to `message`), and opencode exposes no + * endpoint that creates a message without invoking a model — so this is the + * only message available to attach synthesized tool parts to. + */ + messageID?: string; + /** + * Append a rendered markdown chunk as a noReply user message. Calls are + * serialized through an internal promise chain so concurrent flushes post + * in order (no interleaving). + */ + flush(markdown: string): Promise<void>; + /** + * Materialise a `tool` part in the child session, which is what the TUI's + * subagent card reads to render its live activity subtitle. Pass the + * returned id back as `partID` to flip the same call to `completed`. + * Resolves `undefined` when the write was skipped or failed. + */ + toolPart(opts: { + callID: string; + tool: string; + status: "running" | "completed"; + title?: string; + input?: unknown; + output?: string; + partID?: string; + start: number; + end?: number; + }): Promise<string | undefined>; + /** + * Final flush of any remaining buffered content plus an optional activity + * line, then mark the handle done. Further flushes become no-ops. + */ + finalize(activity?: string): Promise<void>; +} + +/** + * Create a child session for a Cursor subagent up-front and return a live + * handle for streaming its activity. Seeds the originating prompt as the first + * noReply message. Returns `undefined` when the bridge is unavailable or any + * step fails (the caller then degrades to the post-completion link). + */ +export async function linkSubagentSessionLive(opts: { + parentSessionID: string; + args: unknown; +}): Promise<SubagentLiveSession | undefined> { + const bridge = getSubagentBridge(); + if (!bridge) return undefined; + const { client, directory } = bridge; + const query = directory ? { directory } : undefined; + try { + const description = strField(opts.args, "description") ?? "Subagent task"; + const agent = subagentLabel(opts.args); + const created = await client.session.create({ + body: { + parentID: opts.parentSessionID, + title: `${description} (@${agent} subagent)`, + }, + ...(query ? { query } : {}), + }); + const childId = created?.data?.id; + if (!childId) return undefined; + + // `noReply` short-circuits before the model loop and returns the created + // USER message (`session/prompt.ts:1069`), despite the generated SDK + // typing it as an AssistantMessage. Its id is what child parts hang off. + let messageID: string | undefined; + const prompt = strField(opts.args, "prompt"); + if (prompt) { + const seeded = await client.session.prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text: prompt }] }, + }); + messageID = strField( + (seeded?.data as { info?: unknown } | undefined)?.info, + "id", + ); + } + + let done = false; + let chain: Promise<void> = Promise.resolve(); + const post = (text: string): Promise<void> => { + chain = chain.then(() => + client.session + .prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text }] }, + }) + .then(() => undefined) + .catch(() => undefined), + ); + return chain; + }; + + return { + childId, + messageID, + flush: (markdown: string) => (done ? Promise.resolve() : post(markdown)), + toolPart: async (part) => { + if (done || !messageID) return undefined; + const partID = part.partID ?? createPartID(); + const written = await upsertToolPart({ + sessionID: childId, + messageID, + partID, + callID: part.callID, + tool: part.tool, + status: part.status, + title: part.title, + input: part.input, + output: part.output, + start: part.start, + end: part.end, + }); + return written ? partID : undefined; + }, + finalize: async (activity?: string) => { + if (done) return; + done = true; + if (activity) await post(activity); + }, + }; + } catch { + // Best-effort: a failed link must never break the turn. + return undefined; + } +} + +/** + * Create a child session for a completed `cursor_delegate` turn and seed it + * with the originating prompt + a rendered transcript (text, reasoning, tool + * activity) as a single noReply message. Returns the child session id, or + * `undefined` when the bridge is unavailable or any step fails. + * + * Unlike the provider `task` path, a custom tool's result is a tool block, not + * a `task` part — `metadata.sessionId` on it does NOT render a navigable card. + * The child session is instead discoverable via the TUI's subagent panel + * (sessions with a `parentID` surface through `/session/{id}/children`). + */ +export async function linkDelegateSession(opts: { + parentSessionID: string; + title: string; + prompt: string; + transcript: string; +}): Promise<string | undefined> { + const bridge = getSubagentBridge(); + if (!bridge) return undefined; + const { client, directory } = bridge; + const query = directory ? { directory } : undefined; + try { + const created = await client.session.create({ + body: { parentID: opts.parentSessionID, title: opts.title }, + ...(query ? { query } : {}), + }); + const childId = created?.data?.id; + if (!childId) return undefined; + if (opts.prompt) { + await client.session.prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text: opts.prompt }] }, + }); + } + if (opts.transcript) { + await client.session.prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text: opts.transcript }] }, + }); + } + return childId; + } catch { + // Best-effort: a failed link must never break the turn. + return undefined; + } +} diff --git a/src/provider/subagent-stream.ts b/src/provider/subagent-stream.ts new file mode 100644 index 0000000..62982ca --- /dev/null +++ b/src/provider/subagent-stream.ts @@ -0,0 +1,255 @@ +import type { SubagentNestedEvent } from "./agent-events.js"; +import { + renderConversationSteps, + resultText, + type SubagentLiveSession, +} from "./subagent-bridge.js"; + +/** Keys a Cursor tool input may carry, best-title-first. */ +const TITLE_KEYS = ["path", "command", "pattern", "query", "server"] as const; + +/** + * Derive a short label for a tool call, playing the role opencode's own + * `state.title` plays — it is what the TUI renders after the tool name in the + * subagent card's `↳ <Tool> <title>` subtitle. + */ +function toolTitle(input: unknown): string | undefined { + if (typeof input !== "object" || input === null) return undefined; + const record = input as Record<string, unknown>; + for (const key of TITLE_KEYS) { + const value = record[key]; + if (typeof value === "string" && value) return value; + } + return undefined; +} + +/** + * Accumulate a Cursor subagent's nested activity (text, reasoning, tool calls) + * and flush it into the linked child session in batched markdown messages. + * + * The opencode public API can only add user-role messages to a child session + * (`session.prompt({ noReply: true })`), so the transcript renders as a + * sequence of user messages. Batching keeps the session API load low while + * still surfacing activity live: text deltas are coalesced on a time window, + * and tool results flush promptly so tool activity appears as it happens. + */ +export class SubagentTranscriptSink { + /** Flush when this much time has elapsed since the last flush. */ + private static readonly FLUSH_INTERVAL_MS = 1500; + + private readonly session: SubagentLiveSession; + private text = ""; + private reasoning = ""; + private readonly tools: string[] = []; + private pending = false; + private lastFlush = 0; + private timer: ReturnType<typeof setTimeout> | undefined; + private done = false; + /** Nested call id → the running tool part written for it. */ + private readonly partHandles = new Map< + string, + { + partID: string; + callID: string; + tool: string; + title?: string; + input: unknown; + start: number; + } + >(); + /** Serialises tool-part writes so a result never overtakes its start. */ + private partChain: Promise<void> = Promise.resolve(); + private anonSeq = 0; + + /** Correlation key for a nested event that arrived without a call id. */ + private nestedKey(id: string): string { + return id || `anon-${++this.anonSeq}`; + } + + /** Enqueue a tool-part write; fire-and-forget, never throws. */ + private enqueuePart(write: () => Promise<unknown>): void { + this.partChain = this.partChain + .then(async () => { + await write(); + }) + .catch(() => undefined); + } + + constructor(session: SubagentLiveSession) { + this.session = session; + } + + /** The linked child session id (for stamping the task card's sessionId). */ + get childId(): string { + return this.session.childId; + } + + /** Feed a normalized nested subagent event into the sink. */ + push(event: SubagentNestedEvent): void { + if (this.done) return; + switch (event.type) { + case "text": + this.text += event.text; + this.pending = true; + break; + case "reasoning": + this.reasoning += event.text; + this.pending = true; + break; + case "tool-start": { + this.tools.push(`**\`${event.name}\`** ${formatArgs(event.input)}`); + this.pending = true; + // A real `tool` part in the child session — this is what the TUI's + // subagent card reads for its live `↳ <Tool> <title>` subtitle. + const key = this.nestedKey(event.id); + const start = Date.now(); + this.enqueuePart(async () => { + const partID = await this.session.toolPart({ + callID: key, + tool: event.name, + status: "running", + title: toolTitle(event.input), + input: event.input, + start, + }); + if (partID) { + this.partHandles.set(key, { + partID, + callID: key, + tool: event.name, + title: toolTitle(event.input), + input: event.input, + start, + }); + } + }); + break; + } + case "tool-result": { + this.tools.push(formatResult(event.name, event.result, event.isError)); + this.pending = true; + // Complete the matching running part. A result with no observed + // start (sink attached late) still gets a completed part so the + // child session reflects every call the subagent made. + const key = event.id || `result-${++this.anonSeq}`; + this.enqueuePart(async () => { + const handle = this.partHandles.get(key); + this.partHandles.delete(key); + await this.session.toolPart({ + callID: handle?.callID ?? key, + tool: event.name, + status: "completed", + title: handle?.title, + input: handle?.input, + partID: handle?.partID, + start: handle?.start ?? Date.now(), + end: Date.now(), + }); + }); + // Tool results flush promptly so activity appears as it happens. + this.flushNow(); + return; + } + } + this.armTimer(); + } + + /** + * Flush any buffered content, then append the subagent's final answer + * (`resultSuffix`), a render of its `conversationSteps` (its own + * text/thinking/tool activity), and the optional activity line, and mark + * the sink done. Further pushes and flushes become no-ops. + */ + async finalize(resultValue?: unknown, activity?: string): Promise<void> { + if (this.done) return; + this.done = true; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + const body = this.render(); + if (body) await this.session.flush(body); + const suffix = + typeof resultValue === "object" && resultValue !== null + ? (resultValue as Record<string, unknown>)["resultSuffix"] + : undefined; + if (typeof suffix === "string" && suffix) await this.session.flush(suffix); + const steps = renderConversationSteps(resultValue); + if (steps) await this.session.flush(steps); + // Complete any tool calls still open — a subagent that ended without a + // tool-result event would otherwise leave parts `running` forever. Must + // precede session.finalize(), which closes the handle to further writes. + await this.partChain; + for (const [, handle] of this.partHandles) { + await this.session.toolPart({ + callID: handle.callID, + tool: handle.tool, + status: "completed", + title: handle.title, + input: handle.input, + partID: handle.partID, + start: handle.start, + end: Date.now(), + }); + } + this.partHandles.clear(); + if (activity) await this.session.finalize(activity); + else await this.session.finalize(); + } + + private armTimer(): void { + if (this.done || this.timer) return; + const elapsed = Date.now() - this.lastFlush; + const delay = Math.max(0, SubagentTranscriptSink.FLUSH_INTERVAL_MS - elapsed); + this.timer = setTimeout(() => { + this.timer = undefined; + this.flushNow(); + }, delay); + this.timer.unref?.(); + } + + private flushNow(): void { + if (this.done) return; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + if (!this.pending) return; + const body = this.render(); + this.pending = false; + this.lastFlush = Date.now(); + if (body) void this.session.flush(body); + } + + /** Render the accumulated activity into a single markdown message. */ + private render(): string { + const parts: string[] = []; + if (this.text.trim()) parts.push(this.text.trim()); + if (this.reasoning.trim()) parts.push(`> ${this.reasoning.trim()}`); + if (this.tools.length > 0) parts.push(this.tools.join("\n\n")); + const body = parts.join("\n\n").trim(); + // Consume the rendered buffers so a later flush only carries new content. + this.text = ""; + this.reasoning = ""; + this.tools.length = 0; + return body; + } +} + +/** Render a tool call's arguments as a compact inline string. */ +function formatArgs(input: unknown): string { + let s = ""; + try { + s = typeof input === "string" ? input : JSON.stringify(input); + } catch { + return ""; + } + if (!s || s === "{}" || s === '""') return ""; + return s; +} + +/** Render a tool result as a fenced block (or an error marker). */ +function formatResult(name: string, result: unknown, isError: boolean): string { + if (isError) return `**\`${name}\`** — _failed_`; + const text = resultText(result); + if (!text) return `**\`${name}\`** — _done_`; + return `**\`${name}\`**\n\n\`\`\`\n${text}\n\`\`\``; +} diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index 185cd48..23d8adb 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -323,6 +323,85 @@ describe("streamAgentTurn MCP error surfacing", () => { }); }); +describe("streamAgentTurn nested subagent (tool-call-delta)", () => { + it("normalizes nested text/tool updates into subagent-event", async () => { + const agent = fakeAgent({ + updates: [ + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "m1", + taskUpdate: { type: "text-delta", text: "hello " }, + }, + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "m1", + taskUpdate: { + type: "tool-call-started", + callId: "sub-1", + toolCall: { type: "shell", args: { command: "git status" } }, + }, + }, + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "m1", + taskUpdate: { + type: "tool-call-completed", + callId: "sub-1", + modelCallId: "m1", + toolCall: { + type: "shell", + args: { command: "git status" }, + result: { status: "success", value: { stdout: "clean" } }, + }, + }, + }, + ], + result: { status: "finished", result: "" }, + }); + + const events = await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + const sub = events.filter((e) => e.type === "subagent-event"); + expect(sub).toHaveLength(3); + expect(sub[0]).toMatchObject({ callId: "task-1", event: { type: "text", text: "hello " } }); + expect(sub[1]).toMatchObject({ + callId: "task-1", + event: { type: "tool-start", name: "shell", id: "sub-1" }, + }); + expect(sub[2]).toMatchObject({ + callId: "task-1", + event: { type: "tool-result", name: "shell", id: "sub-1", isError: false }, + }); + }); + + it("normalizes nested thinking deltas and drops non-renderable updates", async () => { + const agent = fakeAgent({ + updates: [ + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "m1", + taskUpdate: { type: "thinking-delta", text: "reasoning…" }, + }, + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "m1", + taskUpdate: { type: "step-started", stepId: 1 }, + }, + ], + result: { status: "finished", result: "" }, + }); + + const events = await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + const sub = events.filter((e) => e.type === "subagent-event"); + expect(sub).toHaveLength(1); + expect(sub[0]).toMatchObject({ event: { type: "reasoning", text: "reasoning…" } }); + }); +}); + describe("streamAgentTurn idempotency key", () => { it("passes idempotencyKey through to agent.send", async () => { const sendCalls: Array<Record<string, unknown> | undefined> = []; diff --git a/test/child-parts.test.ts b/test/child-parts.test.ts new file mode 100644 index 0000000..20e8e28 --- /dev/null +++ b/test/child-parts.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPartID, upsertToolPart } from "../src/provider/child-parts.js"; +import { + clearSubagentBridge, + setSubagentBridge, +} from "../src/provider/subagent-bridge.js"; + +afterEach(() => clearSubagentBridge()); + +type RawRequest = (options: Record<string, unknown>) => Promise<unknown>; + +/** Publish a bridge whose raw client records every request it receives. */ +function fakeBridge(impl?: RawRequest) { + const request = vi.fn<RawRequest>(impl ?? (async () => ({}))); + setSubagentBridge({ + client: { _client: { request } } as never, + directory: "/w", + }); + return request; +} + +/** The options object handed to the raw client for call `index`. */ +function requestAt( + request: ReturnType<typeof fakeBridge>, + index: number, +): Record<string, unknown> { + const call = request.mock.calls[index]; + if (!call) throw new Error(`no request at index ${index}`); + return call[0]; +} + +describe("createPartID", () => { + it("matches opencode's ascending part id format", () => { + // packages/opencode/src/id/id.ts:51 — prefix + "_" + 6 hex bytes + 14 base62. + // Verified against a real row: prt_fd90281ed001Zwm05cey7wh2ym + expect(createPartID()).toMatch(/^prt_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + }); + + it("stays ordered and unique within a single millisecond", () => { + const ids = Array.from({ length: 50 }, () => createPartID(1786052248042)); + expect([...ids].sort()).toEqual(ids); + expect(new Set(ids).size).toBe(50); + }); +}); + +describe("upsertToolPart", () => { + it("PATCHes a running tool part carrying a title", async () => { + const request = fakeBridge(); + const ok = await upsertToolPart({ + sessionID: "ses_c", + messageID: "msg_1", + partID: createPartID(), + callID: "c1", + tool: "read", + status: "running", + title: "CHANGELOG.md", + input: { path: "CHANGELOG.md" }, + start: 5, + }); + expect(ok).toBe(true); + const call = requestAt(request, 0); + expect(call["method"]).toBe("PATCH"); + expect(call["url"]).toBe( + "/session/{sessionID}/message/{messageID}/part/{partID}", + ); + expect(call["body"]).toMatchObject({ + type: "tool", + tool: "read", + state: { status: "running", title: "CHANGELOG.md", time: { start: 5 } }, + }); + }); + + it("sends path params the endpoint validates against the body", async () => { + const request = fakeBridge(); + const partID = createPartID(); + await upsertToolPart({ + sessionID: "ses_c", + messageID: "msg_1", + partID, + callID: "c1", + tool: "bash", + status: "completed", + title: "git status", + output: "clean", + start: 1, + end: 2, + }); + // handlers/session.ts:403-409 rejects the request unless these match. + const call = requestAt(request, 0); + expect(call["path"]).toEqual({ + sessionID: "ses_c", + messageID: "msg_1", + partID, + }); + expect(call["body"]).toMatchObject({ + id: partID, + messageID: "msg_1", + sessionID: "ses_c", + state: { status: "completed", output: "clean", time: { start: 1, end: 2 } }, + }); + }); + + // The completed ToolState schema REQUIRES state.metadata (the running state + // does not) — omitting it got a 400 from a live v1.18.18 server, which + // hey-api reports as a resolved `{ error }` rather than a rejection. + it("sends state.metadata on a completed part, and fails on an {error} response", async () => { + const request = fakeBridge(async () => ({ + error: { name: "BadRequest", data: { message: "Missing key" } }, + })); + const ok = await upsertToolPart({ + sessionID: "ses_c", + messageID: "msg_1", + partID: createPartID(), + callID: "c1", + tool: "read", + status: "completed", + output: "", + start: 1, + end: 2, + }); + expect(ok).toBe(false); + const call = requestAt(request, 0); + expect( + (call["body"] as { state: Record<string, unknown> }).state["metadata"], + ).toEqual({}); + }); + + it("returns false and never throws when the request fails", async () => { + fakeBridge(async () => { + throw new Error("boom"); + }); + await expect( + upsertToolPart({ + sessionID: "s", + messageID: "m", + partID: createPartID(), + callID: "c", + tool: "read", + status: "running", + start: 0, + }), + ).resolves.toBe(false); + }); + + it("returns false when no bridge is published", async () => { + const ok = await upsertToolPart({ + sessionID: "s", + messageID: "m", + partID: createPartID(), + callID: "c", + tool: "read", + status: "running", + start: 0, + }); + expect(ok).toBe(false); + }); +}); diff --git a/test/cursor-tools.test.ts b/test/cursor-tools.test.ts index 9bfe874..1847849 100644 --- a/test/cursor-tools.test.ts +++ b/test/cursor-tools.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const runCloudAgent = vi.fn(); const runDelegate = vi.fn(); +const linkDelegateSession = vi.fn(); vi.mock("../src/provider/cloud-agent.js", () => ({ runCloudAgent })); vi.mock("../src/provider/delegate.js", () => ({ runDelegate })); +vi.mock("../src/provider/subagent-bridge.js", () => ({ linkDelegateSession })); const { buildCursorTools } = await import("../src/plugin/cursor-tools.js"); @@ -27,6 +29,7 @@ const noKey = { resolveApiKey: () => undefined, defaultCwd: () => "/work" }; afterEach(() => { runCloudAgent.mockReset(); runDelegate.mockReset(); + linkDelegateSession.mockReset(); }); describe("buildCursorTools", () => { @@ -104,6 +107,52 @@ describe("buildCursorTools", () => { expect(out.metadata.agentId).toBe("a1"); }); + it("links the delegate to a child session seeded with the transcript", async () => { + runDelegate.mockResolvedValue({ + agentId: "a1", + text: "result text", + reasoning: "thinking…", + toolActivity: [{ name: "read", isError: false }], + usage: undefined, + }); + linkDelegateSession.mockResolvedValue("ses_child"); + const ask = vi.fn().mockResolvedValue(undefined); + const tools = buildCursorTools(withKey); + + await tools.cursor_delegate!.execute( + { prompt: "p", model: "m" } as any, + ctx(ask), + ); + + expect(linkDelegateSession).toHaveBeenCalledOnce(); + const linkArgs = linkDelegateSession.mock.calls[0]![0]; + expect(linkArgs).toMatchObject({ + parentSessionID: "s", + title: "Cursor delegate (m)", + prompt: "p", + }); + expect(linkArgs.transcript).toContain("result text"); + expect(linkArgs.transcript).toContain("thinking…"); + expect(linkArgs.transcript).toContain("1 tool call"); + }); + + it("skips child-session linking when the delegate has no sessionID", async () => { + runDelegate.mockResolvedValue({ + agentId: "a1", + text: "result text", + reasoning: "", + toolActivity: [], + usage: undefined, + }); + const ask = vi.fn().mockResolvedValue(undefined); + const tools = buildCursorTools(withKey); + const context = { ...ctx(ask), sessionID: undefined }; + + await tools.cursor_delegate!.execute({ prompt: "p", model: "m" } as any, context); + + expect(linkDelegateSession).not.toHaveBeenCalled(); + }); + it("returns needs-auth for the cloud agent tool when no API key is available", async () => { const tools = buildCursorTools(noKey); const out = await tools.cursor_cloud_agent!.execute( diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index dd9aa80..e68aef8 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + import type { CursorEvent } from "../src/provider/agent-events.js"; import { cursorEventsToContent, @@ -10,6 +11,7 @@ import { import { clearSubagentBridge, setSubagentBridge, + subagentCallChildId, } from "../src/provider/subagent-bridge.js"; async function* gen(events: CursorEvent[]): AsyncGenerator<CursorEvent> { @@ -291,12 +293,8 @@ describe("cursorEventsToStream", () => { const reasoning = parts .filter( - ( - p, - ): p is Extract< - LanguageModelV3StreamPart, - { type: "reasoning-delta" } - > => p.type === "reasoning-delta", + (p): p is Extract<LanguageModelV3StreamPart, { type: "reasoning-delta" }> => + p.type === "reasoning-delta", ) .map((p) => p.delta) .join(""); @@ -428,10 +426,7 @@ describe("cursorEventsToStream", () => { { type: "tool-call", id: "c1", name: "semSearch", input: { query: "x" } }, ]; const parts = await collect( - cursorEventsToStream( - genThenThrow(events, new Error("run died")), - "blocks", - ), + cursorEventsToStream(genThenThrow(events, new Error("run died")), "blocks"), ); const result = parts.find((p) => p.type === "tool-result") as Record< string, @@ -796,9 +791,10 @@ describe("native edit mapping (blocks)", () => { ]), "blocks", ); - const call = content.find( - (c) => c.type === "tool-call", - ) as unknown as Record<string, unknown>; + const call = content.find((c) => c.type === "tool-call") as unknown as Record< + string, + unknown + >; const result = content.find((c) => c.type === "tool-result") as unknown as { result: Record<string, unknown>; }; @@ -983,9 +979,7 @@ describe("native tool mapping (blocks)", () => { ws: { type: "content", output: { - matches: [ - { file: "/src/a.ts", lineNumber: 3, line: "const foo = 1" }, - ], + matches: [{ file: "/src/a.ts", lineNumber: 3, line: "const foo = 1" }], totalMatches: 1, }, }, @@ -1162,8 +1156,6 @@ describe("native tool mapping (blocks)", () => { }); }); - - it("formats Cursor `readLints` as a `cursor_readLints` diagnostics list", async () => { const { call, result } = await mapTool( "readLints", @@ -1602,8 +1594,8 @@ describe("subagent child-session linking (blocks)", () => { ]; const foldedMetadata = (part: LanguageModelV3StreamPart) => - (part as unknown as { result: { metadata?: Record<string, unknown> } }) - .result.metadata ?? {}; + (part as unknown as { result: { metadata?: Record<string, unknown> } }).result + .metadata ?? {}; // Minimal opencode client stub capturing the create/prompt calls the bridge // makes; returns a fake child session id. @@ -1620,7 +1612,9 @@ describe("subagent child-session linking (blocks)", () => { }, prompt: async (opts: unknown) => { calls.prompt.push(opts); - return { data: {} }; + // A real noReply prompt resolves `{ info: { id } }` — the seeded user + // message, which child tool parts attach to. + return { data: { info: { id: "msg_seed" } } }; }, }, }; @@ -1643,18 +1637,21 @@ describe("subagent child-session linking (blocks)", () => { // The linked child session id makes the card clickable / ctrl+x-navigable. expect(foldedMetadata(result)).toMatchObject({ sessionId: "ses_child" }); // Child created under the parent with the "(@agent subagent)" title, then - // seeded with the prompt + transcript (two noReply prompts). + // seeded with the prompt + the subagent's final answer + the activity + // line (three noReply prompts on the live path). expect(calls.create[0]).toMatchObject({ body: { parentID: "ses_parent", title: expect.stringContaining("(@") }, query: { directory: "/repo" }, }); - expect(calls.prompt.length).toBe(2); - // The transcript message carries Cursor's result + its real duration. - const transcript = calls.prompt[1] as { - body: { parts: Array<{ text: string }> }; - }; - expect(transcript.body.parts[0]!.text).toContain("done"); - expect(transcript.body.parts[0]!.text).toContain("5.0s"); + expect(calls.prompt.length).toBe(3); + const texts = ( + calls.prompt as Array<{ body: { parts: Array<{ text: string }> } }> + ) + .map((p) => p.body.parts[0]!.text) + .join("\n"); + // The transcript carries Cursor's result + its real duration. + expect(texts).toContain("done"); + expect(texts).toContain("5.0s"); }); it("degrades to a non-navigable card when no bridge is published", async () => { @@ -1696,26 +1693,219 @@ describe("subagent child-session linking (blocks)", () => { const result = toolResults(parts)[0]!; expect(foldedMetadata(result)["sessionId"]).toBeUndefined(); }); + + it("streams nested subagent activity into a live child session", async () => { + const { client, calls } = stubClient(); + // Capture tool-part upserts (the live `↳ <Tool> <title>` subtitle source). + const partWrites: Array<Record<string, unknown>> = []; + (client as Record<string, unknown>)["_client"] = { + request: async (opts: Record<string, unknown>) => { + partWrites.push(opts); + return {}; + }, + }; + setSubagentBridge({ + client: client as never, + directory: "/repo", + }); + const liveEvents: CursorEvent[] = [ + { + type: "tool-call", + id: "t1", + name: "task", + input: { + description: "Pull current branch", + prompt: "pull dev", + subagentType: { kind: "unspecified" }, + }, + }, + { + type: "subagent-event", + callId: "t1", + event: { type: "text", text: "working on it" }, + }, + { + type: "subagent-event", + callId: "t1", + event: { + type: "tool-start", + id: "s1", + name: "shell", + input: { command: "git status" }, + }, + }, + { + type: "subagent-event", + callId: "t1", + event: { + type: "tool-result", + id: "s1", + name: "shell", + result: { status: "success", value: { stdout: "clean" } }, + isError: false, + }, + }, + { + type: "tool-result", + id: "t1", + name: "task", + result: { + status: "success", + value: { + isBackground: false, + durationMs: 5000, + resultSuffix: "done", + conversationSteps: [{ assistantMessage: { text: "subagent text" } }], + }, + }, + isError: false, + }, + { type: "finish" }, + ]; + + // Assert the call→child mapping is registered WHILE the subagent runs: + // the mapper registers it when it processes the tool-call (before the + // tool-result unregisters it). The provider cannot carry the id on the + // tool-call part itself — providerMetadata on V3 tool-call parts would + // land in part-level metadata, and opencode's ProviderMetadata schema + // rejects bare strings — while the TUI card reads state.metadata. The + // plugin's event hook patches the running part from this registry. + async function* liveGen(): AsyncGenerator<CursorEvent> { + for (const e of liveEvents) { + yield e; + if (e.type === "tool-call") { + expect(subagentCallChildId("t1")).toBe("ses_child"); + } + } + } + const parts = await collect( + cursorEventsToStream(liveGen(), "blocks", { + sessionID: "ses_parent", + }), + ); + const result = toolResults(parts)[0]!; + // The live child session id makes the card navigable. + expect(foldedMetadata(result)).toMatchObject({ sessionId: "ses_child" }); + // The mapping is released once the task completes. + expect(subagentCallChildId("t1")).toBeUndefined(); + // Child created up-front (on the tool-call), not at the result. + expect(calls.create.length).toBe(1); + // The prompt was seeded, then the nested activity flushed as markdown. + const promptTexts = ( + calls.prompt as Array<{ body: { parts: Array<{ text: string }> } }> + ) + .map((p) => p.body.parts[0]!.text) + .join("\n"); + expect(promptTexts).toContain("pull dev"); + expect(promptTexts).toContain("working on it"); + expect(promptTexts).toContain("shell"); + expect(promptTexts).toContain("git status"); + // The subagent's own text and final answer land in the child session. + expect(promptTexts).toContain("subagent text"); + expect(promptTexts).toContain("done"); + // The activity line is appended on finalize. + expect(promptTexts).toContain("5.0s"); + // The nested tool call produced a running then completed tool part, the + // completion reusing the running part's id (upsert, not a second part). + const bodies = partWrites.map((w) => w["body"] as Record<string, unknown>); + const toolStates = bodies + .filter((b) => b["type"] === "tool") + .map((b) => ({ + id: b["id"], + tool: b["tool"], + status: (b["state"] as Record<string, unknown>)["status"], + title: (b["state"] as Record<string, unknown>)["title"], + })); + expect(toolStates).toEqual([ + { + id: expect.stringMatching(/^prt_/), + tool: "shell", + status: "running", + title: "git status", + }, + { + id: expect.stringMatching(/^prt_/), + tool: "shell", + status: "completed", + title: "git status", + }, + ]); + expect(toolStates[1]!["id"]).toBe(toolStates[0]!["id"]); + }); + + it("falls back to post-completion linking when no live sink was created", async () => { + // No bridge → no live sink; the post-completion link is skipped entirely + // (matching the "no bridge" degrade), so the card stays non-navigable. + const parts = await collect( + cursorEventsToStream(gen(taskEvents), "blocks", { + sessionID: "ses_parent", + }), + ); + const result = toolResults(parts)[0]!; + expect(foldedMetadata(result)["sessionId"]).toBeUndefined(); + }); + + it("releases the call→child mapping when the task errors", async () => { + const { client } = stubClient(); + setSubagentBridge({ + client: client as never, + directory: "/repo", + }); + const failingEvents: CursorEvent[] = [ + { + type: "tool-call", + id: "t9", + name: "task", + input: { description: "Failing task", prompt: "boom" }, + }, + { + type: "tool-result", + id: "t9", + name: "task", + result: { status: "error", value: { message: "failed" } }, + isError: true, + }, + { type: "finish" }, + ]; + async function* failingGen(): AsyncGenerator<CursorEvent> { + for (const e of failingEvents) { + yield e; + if (e.type === "tool-call") { + expect(subagentCallChildId("t9")).toBe("ses_child"); + } + } + } + await collect( + cursorEventsToStream(failingGen(), "blocks", { + sessionID: "ses_parent", + }), + ); + expect(subagentCallChildId("t9")).toBeUndefined(); + }); }); describe("effectiveToolDisplay", () => { - it("returns \"reasoning\" when tools are undefined", () => { + it('returns "reasoning" when tools are undefined', () => { expect(effectiveToolDisplay("blocks", undefined)).toBe("reasoning"); }); - it("returns \"reasoning\" when tools are an empty array", () => { + it('returns "reasoning" when tools are an empty array', () => { expect(effectiveToolDisplay("blocks", [])).toBe("reasoning"); }); it("returns the configured mode when tools are present", () => { - expect(effectiveToolDisplay("blocks", [{ type: "function", name: "read" } as never])).toBe( - "blocks", - ); + expect( + effectiveToolDisplay("blocks", [ + { type: "function", name: "read" } as never, + ]), + ).toBe("blocks"); }); - it("defaults to \"blocks\" when configured is undefined and tools are present", () => { + it('defaults to "blocks" when configured is undefined and tools are present', () => { expect( - effectiveToolDisplay(undefined, [{ type: "function", name: "read" } as never]), + effectiveToolDisplay(undefined, [ + { type: "function", name: "read" } as never, + ]), ).toBe("blocks"); }); }); diff --git a/test/subagent-bridge.test.ts b/test/subagent-bridge.test.ts new file mode 100644 index 0000000..4307a15 --- /dev/null +++ b/test/subagent-bridge.test.ts @@ -0,0 +1,515 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import plugin from "../src/plugin/index.js"; +import { + clearSubagentBridge, + linkSubagentSessionLive, + registerSubagentCall, + renderConversationSteps, + setSubagentBridge, + stampTaskPartSessionId, + subagentCallChildId, + unregisterSubagentCall, +} from "../src/provider/subagent-bridge.js"; + +describe("linkSubagentSessionLive tool parts", () => { + afterEach(() => clearSubagentBridge()); + + /** `session.prompt` with `noReply` returns the created USER message + * (`session/prompt.ts:1069`), whose id owns the child's parts. */ + function bridge() { + const request = vi.fn(async (_options: Record<string, unknown>): Promise<unknown> => ({})); + const prompt = vi.fn(async () => ({ data: { info: { id: "msg_seed" }, parts: [] } })); + const create = vi.fn(async () => ({ data: { id: "ses_child" } })); + setSubagentBridge({ + client: { session: { create, prompt }, _client: { request } } as never, + directory: "/w", + }); + return { request }; + } + + it("captures the seeded message id and writes a tool part to it", async () => { + const { request } = bridge(); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "do the thing" }, + }); + expect(live?.messageID).toBe("msg_seed"); + const partID = await live?.toolPart({ + callID: "c1", + tool: "read", + title: "a.ts", + status: "running", + start: 1, + }); + expect(partID).toMatch(/^prt_/); + expect(request).toHaveBeenCalledTimes(1); + const body = request.mock.calls[0]![0]["body"] as Record<string, unknown>; + expect(body["messageID"]).toBe("msg_seed"); + expect(body["tool"]).toBe("read"); + }); + + it("reuses a caller-supplied part id so a call can be flipped to completed", async () => { + const { request } = bridge(); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "p" }, + }); + const partID = await live?.toolPart({ + callID: "c1", + tool: "bash", + status: "running", + start: 1, + }); + await live?.toolPart({ + callID: "c1", + tool: "bash", + status: "completed", + partID, + start: 1, + end: 2, + }); + expect(request).toHaveBeenCalledTimes(2); + const second = request.mock.calls[1]![0]["body"] as Record<string, unknown>; + expect(second["id"]).toBe(partID); + expect(second["state"]).toMatchObject({ status: "completed" }); + }); + + it("stops writing tool parts once finalized", async () => { + const { request } = bridge(); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "p" }, + }); + await live?.finalize(); + await expect( + live?.toolPart({ callID: "c1", tool: "read", status: "running", start: 1 }), + ).resolves.toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + }); +}); + +/** + * Cursor returns `conversationSteps` as raw protobuf-es `toJson()` output of + * `agent.v1.ConversationStep`, whose `message` oneof serializes to a single + * camelCase key — NOT the `{ type, message }` shape of the SDK's public zod + * type. `agent.v1.ToolCall` nests the same way (`<tool>ToolCall` keys). + */ +describe("renderConversationSteps (proto oneof shape)", () => { + it("renders assistant text from a oneof-keyed step", () => { + const out = renderConversationSteps({ + conversationSteps: [{ assistantMessage: { text: "the answer is 42" } }], + }); + expect(out).toBe("the answer is 42"); + }); + + it("renders thinking text as a blockquote", () => { + const out = renderConversationSteps({ + conversationSteps: [{ thinkingMessage: { text: "considering options", durationMs: 12 } }], + }); + expect(out).toBe("> considering options"); + }); + + it("renders a tool call, deriving the name from the oneof key", () => { + const out = renderConversationSteps({ + conversationSteps: [ + { toolCall: { shellToolCall: { args: { command: "ls -la" } } } }, + ], + }); + expect(out).toContain("shell"); + expect(out).toContain("ls -la"); + }); + + it("renders every step of a mixed transcript in order", () => { + const out = renderConversationSteps({ + conversationSteps: [ + { thinkingMessage: { text: "plan" } }, + { toolCall: { readToolCall: { args: { path: "a.ts" } } } }, + { assistantMessage: { text: "done" } }, + ], + }); + const lines = (out ?? "").split("\n\n"); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe("> plan"); + expect(lines[1]).toContain("read"); + expect(lines[2]).toBe("done"); + }); + + it("renders a tool call's result alongside its args", () => { + const out = renderConversationSteps({ + conversationSteps: [ + { + toolCall: { + shellToolCall: { args: { command: "git status" }, result: { stdout: "clean" } }, + }, + }, + ], + }); + expect(out).toContain("git status"); + expect(out).toContain("clean"); + }); + + it("does not truncate long tool output", () => { + const long = "x".repeat(5000); + const out = renderConversationSteps({ + conversationSteps: [ + { toolCall: { shellToolCall: { args: { command: "cat big" }, result: { stdout: long } } } }, + ], + }); + expect(out).toContain(long); + expect(out).not.toContain("…"); + }); + + it("does not truncate long assistant text", () => { + const long = "y".repeat(5000); + const out = renderConversationSteps({ conversationSteps: [{ assistantMessage: { text: long } }] }); + expect(out).toBe(long); + }); + + // protobuf-es represents a oneof on a live Message as `{ case, value }`. + // The SDK only calls `toJson()` when it exists (`e.toJson?.() ?? e`), so + // steps can reach us in this runtime form rather than as proto JSON. + it("renders assistant text from the protobuf-es runtime oneof", () => { + const out = renderConversationSteps({ + conversationSteps: [{ message: { case: "assistantMessage", value: { text: "hi there" } } }], + }); + expect(out).toBe("hi there"); + }); + + it("renders a tool call from the protobuf-es runtime oneof", () => { + const out = renderConversationSteps({ + conversationSteps: [ + { + message: { + case: "toolCall", + value: { + tool: { + case: "shellToolCall", + value: { args: { command: "git diff" }, result: { stdout: "patched" } }, + }, + }, + }, + }, + ], + }); + expect(out).toContain("shell"); + expect(out).toContain("git diff"); + expect(out).toContain("patched"); + }); + + // A silently-dropped step is what made two wrong shape guesses look + // identical to "no output at all". Always render something. + it("dumps an unrecognized step instead of dropping it", () => { + const out = renderConversationSteps({ + conversationSteps: [{ somethingNew: { detail: "unmapped" } }], + }); + expect(out).toContain("somethingNew"); + expect(out).toContain("unmapped"); + }); + + it("still renders the SDK's public zod shape", () => { + const out = renderConversationSteps({ + conversationSteps: [{ type: "assistantMessage", message: { text: "legacy" } }], + }); + expect(out).toBe("legacy"); + }); + + it("returns undefined when no step carries content", () => { + expect(renderConversationSteps({ conversationSteps: [{}, { assistantMessage: {} }] })).toBeUndefined(); + }); +}); + +afterEach(() => { + clearSubagentBridge(); + unregisterSubagentCall("call-1"); +}); + +describe("subagent call registry", () => { + it("round-trips a call→child mapping", () => { + registerSubagentCall("call-1", "ses_child"); + expect(subagentCallChildId("call-1")).toBe("ses_child"); + unregisterSubagentCall("call-1"); + expect(subagentCallChildId("call-1")).toBeUndefined(); + }); + + it("returns undefined for unknown calls", () => { + expect(subagentCallChildId("nope")).toBeUndefined(); + }); +}); + +describe("stampTaskPartSessionId", () => { + const runningPart = { + id: "part-1", + sessionID: "ses_parent", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "running", + input: { description: "d" }, + title: "d", + time: { start: 1 }, + }, + }; + + it("PATCHes the running part with state.metadata.sessionId", async () => { + const request = vi.fn( + async (_opts: Record<string, unknown>) => ({ data: undefined, response: new Response() }), + ); + setSubagentBridge({ + client: { + _client: { request }, + session: { + message: async () => ({ + data: { info: {}, parts: [runningPart] }, + }), + }, + } as never, + directory: "/repo", + }); + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: runningPart, + childId: "ses_child", + }); + expect(request).toHaveBeenCalledTimes(1); + const opts = request.mock.calls[0]![0] as Record<string, unknown>; expect(opts["method"]).toBe("PATCH"); + expect(opts["url"]).toBe("/session/{sessionID}/message/{messageID}/part/{partID}"); + expect(opts["path"]).toMatchObject({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + }); + expect(opts["query"]).toEqual({ directory: "/repo" }); + const body = opts["body"] as { state: { status: string; metadata?: Record<string, unknown> } }; + expect(body.state["status"]).toBe("running"); + expect(body.state["metadata"]).toMatchObject({ sessionId: "ses_child" }); + }); + + it("skips non-running parts", async () => { + const request = vi.fn(); + setSubagentBridge({ + client: { _client: { request } } as never, + directory: "/repo", + }); + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: { ...runningPart, state: { status: "completed", input: {}, output: "x", title: "t", metadata: {}, time: { start: 1, end: 2 } } }, + childId: "ses_child", + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("is a no-op when already stamped", async () => { + const request = vi.fn(); + setSubagentBridge({ + client: { _client: { request } } as never, + directory: "/repo", + }); + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: { + ...runningPart, + state: { ...runningPart.state, metadata: { sessionId: "ses_child" } }, + }, + childId: "ses_child", + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("is a no-op without a bridge or raw client", async () => { + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: runningPart, + childId: "ses_child", + }); + setSubagentBridge({ + client: {} as never, + directory: "/repo", + }); + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: runningPart, + childId: "ses_child", + }); + }); + + it("swallows PATCH failures", async () => { + const request = vi.fn(async () => { + throw new Error("boom"); + }); + setSubagentBridge({ + client: { + _client: { request }, + session: { + message: async () => ({ + data: { info: {}, parts: [runningPart] }, + }), + }, + } as never, + directory: "/repo", + }); + await expect( + stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: runningPart, + childId: "ses_child", + }), + ).resolves.toBeUndefined(); + }); + + it("skips the PATCH when the part is no longer running", async () => { + const request = vi.fn(); + setSubagentBridge({ + client: { + _client: { request }, + session: { + message: async () => ({ + data: { + info: {}, + parts: [ + { + ...runningPart, + state: { + status: "completed", + input: {}, + output: "x", + title: "t", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + ], + }, + }), + }, + } as never, + directory: "/repo", + }); + await stampTaskPartSessionId({ + sessionID: "ses_parent", + messageID: "msg-1", + partID: "part-1", + part: runningPart, + childId: "ses_child", + }); + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("plugin event hook — running task stamp", () => { + it("patches a registered running task part with the child session id", async () => { + const request = vi.fn( + async (_opts: Record<string, unknown>) => ({ data: undefined, response: new Response() }), + ); + setSubagentBridge({ + client: { + _client: { request }, + session: { + message: async () => ({ + data: { + info: {}, + parts: [ + { + id: "part-1", + sessionID: "ses_parent", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "running", + input: { description: "d" }, + title: "d", + time: { start: 1 }, + }, + }, + ], + }, + }), + }, + } as never, + directory: "/repo", + }); + registerSubagentCall("call-1", "ses_child"); + const hooks = await plugin({} as never); + await hooks.event!({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + sessionID: "ses_parent", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "running", + input: { description: "d" }, + title: "d", + time: { start: 1 }, + }, + }, + }, + } as never, + }); + expect(request).toHaveBeenCalledTimes(1); + const opts = request.mock.calls[0]![0] as { body: { state: { metadata?: Record<string, unknown> } } }; + expect(opts.body.state["metadata"]).toMatchObject({ sessionId: "ses_child" }); + }); + + it("ignores non-task parts and unregistered calls", async () => { + const request = vi.fn(); + setSubagentBridge({ + client: { _client: { request } } as never, + directory: "/repo", + }); + const hooks = await plugin({} as never); + await hooks.event!({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + sessionID: "ses_parent", + messageID: "msg-1", + type: "tool", + callID: "other", + tool: "read", + state: { status: "running", input: {}, title: "x", time: { start: 1 } }, + }, + }, + } as never, + }); + await hooks.event!({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-2", + sessionID: "ses_parent", + messageID: "msg-1", + type: "tool", + callID: "unregistered", + tool: "task", + state: { status: "running", input: {}, title: "x", time: { start: 1 } }, + }, + }, + } as never, + }); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/test/subagent-stream.test.ts b/test/subagent-stream.test.ts new file mode 100644 index 0000000..63d5983 --- /dev/null +++ b/test/subagent-stream.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SubagentLiveSession } from "../src/provider/subagent-bridge.js"; +import { SubagentTranscriptSink } from "../src/provider/subagent-stream.js"; + +/** A fake live session capturing flushed markdown and tool-part writes. */ +function fakeSession(): { + session: SubagentLiveSession; + flushed: string[]; + finalized: string[]; + parts: Array<{ + callID: string; + tool: string; + status: string; + title?: string; + partID?: string; + }>; +} { + const flushed: string[] = []; + const finalized: string[] = []; + const parts: Array<{ + callID: string; + tool: string; + status: string; + title?: string; + partID?: string; + }> = []; + let counter = 0; + return { + flushed, + finalized, + parts, + session: { + childId: "ses_child", + messageID: "msg_seed", + flush: async (markdown: string) => { + flushed.push(markdown); + }, + toolPart: async (part) => { + const partID = part.partID ?? `prt_${++counter}`; + parts.push({ + callID: part.callID, + tool: part.tool, + status: part.status, + title: part.title, + partID, + }); + return partID; + }, + finalize: async (activity?: string) => { + if (activity) finalized.push(activity); + }, + }, + }; +} + +describe("SubagentTranscriptSink", () => { + it("renders text, reasoning, and tool activity into markdown", async () => { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ type: "text", text: "hello world" }); + sink.push({ type: "reasoning", text: "thinking hard" }); + sink.push({ + type: "tool-start", + id: "s1", + name: "shell", + input: { command: "git status" }, + }); + sink.push({ + type: "tool-result", + id: "s1", + name: "shell", + result: { status: "success", value: { stdout: "clean" } }, + isError: false, + }); + await sink.finalize( + { resultSuffix: "done", conversationSteps: [] }, + "_Subagent ran 1 step in 5.0s._", + ); + + const body = flushed.join("\n"); + expect(body).toContain("hello world"); + expect(body).toContain("> thinking hard"); + expect(body).toContain("shell"); + expect(body).toContain("git status"); + expect(body).toContain("clean"); + expect(body).toContain("done"); + }); + + it("marks failed tool results and keeps long output intact", async () => { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ + type: "tool-start", + id: "s1", + name: "shell", + input: { command: "x".repeat(5000) }, + }); + sink.push({ + type: "tool-result", + id: "s1", + name: "shell", + result: { status: "error", error: "boom" }, + isError: true, + }); + await sink.finalize(); + + const body = flushed.join("\n"); + expect(body).toContain("failed"); + // The child session carries the full transcript: nothing is truncated. + expect(body).toContain("x".repeat(5000)); + }); + + it("flushes tool results promptly and coalesces text on a timer", async () => { + vi.useFakeTimers(); + try { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ type: "text", text: "a" }); + // A tool result triggers an immediate flush of the buffered text. + sink.push({ + type: "tool-result", + id: "s1", + name: "read", + result: { status: "success", value: { fileContentAfterWrite: "data" } }, + isError: false, + }); + expect(flushed.join("\n")).toContain("a"); + expect(flushed.join("\n")).toContain("data"); + // Text pushed after the flush is buffered until the timer fires. + sink.push({ type: "text", text: "b" }); + expect(flushed.join("\n")).not.toContain("b"); + await vi.advanceTimersByTimeAsync(2000); + expect(flushed.join("\n")).toContain("b"); + } finally { + vi.useRealTimers(); + } + }); + + it("is a no-op after finalize", async () => { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + await sink.finalize({ resultSuffix: "done" }); + sink.push({ type: "text", text: "late" }); + await sink.finalize({ resultSuffix: "again" }); + expect(flushed.join("\n")).toContain("done"); + expect(flushed.join("\n")).not.toContain("late"); + expect(flushed.join("\n")).not.toContain("again"); + }); + + it("renders conversation steps on finalize", async () => { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + await sink.finalize({ + resultSuffix: "final answer", + conversationSteps: [ + { assistantMessage: { text: "working on it" } }, + { + toolCall: { + shellToolCall: { + args: { command: "git status" }, + result: { stdout: "clean" }, + }, + }, + }, + ], + }); + const body = flushed.join("\n"); + expect(body).toContain("final answer"); + expect(body).toContain("working on it"); + expect(body).toContain("git status"); + expect(body).toContain("clean"); + }); + + it("writes a running then completed tool part per nested tool call", async () => { + const { session, parts } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ + type: "tool-start", + id: "s1", + name: "shell", + input: { command: "git status" }, + }); + sink.push({ + type: "tool-result", + id: "s1", + name: "shell", + result: { status: "success", value: {} }, + isError: false, + }); + await sink.finalize(); + expect(parts.map((p) => `${p.tool}:${p.status}`)).toEqual([ + "shell:running", + "shell:completed", + ]); + expect(parts[0]!.title).toBe("git status"); + // The completion upserts the running part rather than adding a second. + expect(parts[1]!.partID).toBe(parts[0]!.partID); + }); + + it("completes a tool call left open when the subagent ends", async () => { + const { session, parts } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ + type: "tool-start", + id: "s1", + name: "read", + input: { path: "a.ts" }, + }); + await sink.finalize(); + expect(parts.map((p) => `${p.tool}:${p.status}`)).toEqual([ + "read:running", + "read:completed", + ]); + expect(parts[1]!.partID).toBe(parts[0]!.partID); + }); + + it("writes a completed part for a result whose start was never observed", async () => { + const { session, parts } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ + type: "tool-result", + id: "s9", + name: "grep", + result: { status: "success", value: {} }, + isError: false, + }); + await sink.finalize(); + expect(parts.map((p) => `${p.tool}:${p.status}`)).toEqual(["grep:completed"]); + }); +});