diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8c0d3..4584a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.9.1-next.0] — 2026-08-26 (pre-release) + +Fixes the subagent child-session pane fragmenting one flowing answer +into many small messages. Not on `latest`; install with +`npm install @stablekernel/opencode-cursor@next` to test. + +- **Fix: subagent pane shows one growing transcript instead of fragment + messages.** Live activity snapshots were posted as a NEW message on + every flush (the 1.5s timer, every tool result, plus up to four more + on finalize), so a single subagent turn rendered as 5–20 fragments — + a paragraph split mid-sentence across messages. The seeded prompt + message's text part now grows in place: each flush PATCHes it via + `part.update` with the FULL cumulative transcript (the endpoint the + child session's tool parts already use; opencode publishes + `part.updated`, so live views re-render). Falls back to the old + per-flush message only when the seed response carries no parts or the + PATCH fails. Tool activity no longer duplicates into the transcript + markdown — the child session's `tool` parts already render it live on + the subagent card — and `resultSuffix` + `conversationSteps` + the + activity line merge into the single final transcript instead of three + extra messages. + ## [0.9.0] — 2026-08-26 The Cursor agent can now use installed opencode plugins (#104), their diff --git a/package-lock.json b/package-lock.json index 3dafd21..531e9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stablekernel/opencode-cursor", - "version": "0.9.0", + "version": "0.9.1-next.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stablekernel/opencode-cursor", - "version": "0.9.0", + "version": "0.9.1-next.0", "license": "MIT", "dependencies": { "@connectrpc/connect-node": "^2.1.2", diff --git a/package.json b/package.json index 463a738..4249168 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stablekernel/opencode-cursor", - "version": "0.9.0", + "version": "0.9.1-next.0", "description": "opencode provider plugin backed by the official Cursor SDK (@cursor/sdk) — adds a Cursor provider and lists its models", "type": "module", "license": "MIT", diff --git a/src/provider/child-parts.ts b/src/provider/child-parts.ts index 08ed468..b816232 100644 --- a/src/provider/child-parts.ts +++ b/src/provider/child-parts.ts @@ -34,7 +34,8 @@ export function createPartID(now?: number): string { return `prt_${bytes.toString("hex")}${random}`; } -const PART_URL = "/session/{sessionID}/message/{messageID}/part/{partID}"; +export const PART_URL = + "/session/{sessionID}/message/{messageID}/part/{partID}"; /** Arguments describing one tool call to materialise in a child session. */ export interface ToolPartInput { diff --git a/src/provider/subagent-bridge.ts b/src/provider/subagent-bridge.ts index c6af988..5cd34f3 100644 --- a/src/provider/subagent-bridge.ts +++ b/src/provider/subagent-bridge.ts @@ -1,5 +1,5 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; -import { createPartID, upsertToolPart } from "./child-parts.js"; +import { createPartID, PART_URL, upsertToolPart } from "./child-parts.js"; import { pluginLog } from "./log-bridge.js"; /** @@ -545,9 +545,12 @@ export interface SubagentLiveSession { */ 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). + * Replace the child session's transcript with the given cumulative markdown. + * Grows the seeded prompt message's text part in place via `part.update` so + * the whole transcript stays ONE message (flushing new messages per snapshot + * fragments it); degrades to posting a new noReply message when the part id + * is unavailable or the PATCH fails. Calls are serialized through an + * internal promise chain so concurrent flushes post in order. */ flush(markdown: string): Promise; /** @@ -604,7 +607,10 @@ export async function linkSubagentSessionLive(opts: { // `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. + // The response also carries the message's parts; the text part's id is + // what `flush` patches in place so the transcript stays a single message. let messageID: string | undefined; + let transcriptID: string | undefined; const prompt = strField(opts.args, "prompt"); if (prompt) { const seeded = await client.session.prompt({ @@ -612,32 +618,102 @@ export async function linkSubagentSessionLive(opts: { ...(query ? { query } : {}), body: { noReply: true, parts: [{ type: "text", text: prompt }] }, }); - messageID = strField( - (seeded?.data as { info?: unknown } | undefined)?.info, - "id", + const data = seeded?.data as + | { info?: unknown; parts?: unknown[] } + | undefined; + messageID = strField(data?.info, "id"); + const textPart = data?.parts?.find( + (p) => isRecord(p) && p["type"] === "text", ); + transcriptID = strField(textPart, "id"); } let done = false; let chain: Promise = Promise.resolve(); - const post = (text: string): Promise => { - chain = chain.then(() => - client.session - .prompt({ - path: { id: childId }, - ...(query ? { query } : {}), - body: { noReply: true, parts: [{ type: "text", text }] }, - }) - .then(() => undefined) - .catch(() => undefined), - ); + const enqueue = (step: () => Promise): Promise => { + chain = chain.then(step).catch(() => undefined); return chain; }; + const postNow = async (text: string): Promise => { + await client.session.prompt({ + path: { id: childId }, + ...(query ? { query } : {}), + body: { noReply: true, parts: [{ type: "text", text }] }, + }); + }; + const post = (text: string): Promise => enqueue(() => postNow(text)); + // PATCH the seeded text part to the full cumulative transcript. + // `part.update` decodes the payload as `SessionV1.Part` and patches text + // parts in place, publishing `part.updated` (opencode's own streaming + // does the same via updatePart+delta), so live views re-render it. + const patchTranscript = async (text: string): Promise => { + if (!messageID || !transcriptID) return false; + // 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 = ( + client as unknown as { + _client?: { + request?: (options: Record) => Promise; + }; + } + )._client?.request; + if (!request) return false; + try { + const res = await request({ + method: "PATCH", + url: PART_URL, + path: { + sessionID: childId, + messageID, + partID: transcriptID, + }, + ...(query ? { query } : {}), + body: { + id: transcriptID, + messageID, + sessionID: childId, + type: "text", + text, + }, + }); + // 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 + ) + return false; + return true; + } catch { + return false; + } + }; + + // The last flush whose PATCH failed and degraded to a posted message. + // Cumulative flushes supersede it, so a later identical flush (or a + // retry while the PATCH path is broken) must not re-post the same body. + let postedFallback: string | undefined; return { childId, messageID, - flush: (markdown: string) => (done ? Promise.resolve() : post(markdown)), + flush: (markdown: string) => { + if (done) return Promise.resolve(); + return enqueue(async () => { + if (markdown === postedFallback) return; + if (await patchTranscript(markdown)) { + postedFallback = undefined; + return; + } + postedFallback = markdown; + // Direct call: already running inside the chain — re-enqueueing + // would self-await and deadlock. + await postNow(markdown); + }); + }, toolPart: async (part) => { if (done || !messageID) return undefined; const partID = part.partID ?? createPartID(); @@ -659,6 +735,9 @@ export async function linkSubagentSessionLive(opts: { finalize: async (activity?: string) => { if (done) return; done = true; + // The sink merges the activity line into its cumulative transcript; + // a bare finalize (no flush after) only posts when nothing was + // patched yet. if (activity) await post(activity); }, }; diff --git a/src/provider/subagent-stream.ts b/src/provider/subagent-stream.ts index 62982ca..bca2515 100644 --- a/src/provider/subagent-stream.ts +++ b/src/provider/subagent-stream.ts @@ -1,7 +1,6 @@ import type { SubagentNestedEvent } from "./agent-events.js"; import { renderConversationSteps, - resultText, type SubagentLiveSession, } from "./subagent-bridge.js"; @@ -24,14 +23,20 @@ function toolTitle(input: unknown): string | undefined { } /** - * Accumulate a Cursor subagent's nested activity (text, reasoning, tool calls) - * and flush it into the linked child session in batched markdown messages. + * Accumulate a Cursor subagent's nested activity (text, reasoning) and flush + * it into the linked child session as a single growing transcript message. * * 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. + * (`session.prompt({ noReply: true })`), and posting each buffer snapshot as a + * new message fragments a flowing paragraph across many messages. Instead the + * live session grows the seeded message's text part in place (`flush` takes + * the FULL cumulative transcript each time), so the child session renders as + * prompt + one live-updating message. Tool activity is deliberately NOT + * rendered as markdown — the TUI's subagent card already shows it live via + * the `tool` parts this sink writes (`tool-start`/`tool-result`). + * + * Batching keeps the PATCH load low while still surfacing activity live: + * text deltas are coalesced on a time window. */ export class SubagentTranscriptSink { /** Flush when this much time has elapsed since the last flush. */ @@ -40,7 +45,6 @@ export class SubagentTranscriptSink { private readonly session: SubagentLiveSession; private text = ""; private reasoning = ""; - private readonly tools: string[] = []; private pending = false; private lastFlush = 0; private timer: ReturnType | undefined; @@ -97,8 +101,9 @@ export class SubagentTranscriptSink { this.pending = true; break; case "tool-start": { - this.tools.push(`**\`${event.name}\`** ${formatArgs(event.input)}`); - this.pending = true; + // Tool activity renders via the child session's `tool` parts, not + // markdown in the transcript — writing both duplicates it in the + // subagent pane. // A real `tool` part in the child session — this is what the TUI's // subagent card reads for its live `↳ ` subtitle. const key = this.nestedKey(event.id); @@ -126,8 +131,6 @@ export class SubagentTranscriptSink { 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. @@ -146,34 +149,35 @@ export class SubagentTranscriptSink { end: Date.now(), }); }); - // Tool results flush promptly so activity appears as it happens. - this.flushNow(); - return; + break; } } 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. + * Merge the subagent's final answer (`resultSuffix`), a render of its + * `conversationSteps` (its own text/thinking/tool activity), and the + * optional activity line into the cumulative transcript, flush once, 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); + if (typeof suffix === "string" && suffix) this.text += `\n\n${suffix}`; const steps = renderConversationSteps(resultValue); - if (steps) await this.session.flush(steps); + if (steps) this.text += `\n\n${steps}`; + if (activity) this.text += `\n\n${activity}`; + if (this.text.trim() || this.reasoning.trim()) { + this.pending = false; + await this.session.flush(this.render()); + } // 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. @@ -191,8 +195,7 @@ export class SubagentTranscriptSink { }); } this.partHandles.clear(); - if (activity) await this.session.finalize(activity); - else await this.session.finalize(); + await this.session.finalize(); } private armTimer(): void { @@ -219,37 +222,15 @@ export class SubagentTranscriptSink { if (body) void this.session.flush(body); } - /** Render the accumulated activity into a single markdown message. */ + /** + * Render the FULL cumulative transcript (everything pushed so far, plus + * finalize additions). `flush` replaces the growing message's text with + * this, so each flush carries the whole transcript, not just new content. + */ 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 ""; + return parts.join("\n\n").trim(); } - 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/stream-map.test.ts b/test/stream-map.test.ts index e68aef8..0222ee9 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1612,9 +1612,15 @@ describe("subagent child-session linking (blocks)", () => { }, prompt: async (opts: unknown) => { calls.prompt.push(opts); - // A real noReply prompt resolves `{ info: { id } }` — the seeded user - // message, which child tool parts attach to. - return { data: { info: { id: "msg_seed" } } }; + // A real noReply prompt resolves `{ info, parts }` — the seeded + // user message (which child tool parts attach to) and its text + // part (which the growing transcript patches in place). + return { + data: { + info: { id: "msg_seed" }, + parts: [{ id: "prt_seed", type: "text", text: "pull dev" }], + }, + }; }, }, }; @@ -1637,21 +1643,25 @@ 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 + the subagent's final answer + the activity - // line (three noReply prompts on the live path). + // seeded with the prompt. The transcript grows the seed's text part in + // place (one noReply prompt + one fallback message — this stub client has + // no `_client.request`, so the patch degrades to posting), not a stream + // of fragment messages. expect(calls.create[0]).toMatchObject({ body: { parentID: "ses_parent", title: expect.stringContaining("(@") }, query: { directory: "/repo" }, }); - 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"); + expect(calls.prompt.length).toBe(2); + expect( + (calls.prompt[0] as { body: { parts: Array<{ text: string }> } }).body + .parts[0]!.text, + ).toBe("pull dev"); + const transcript = ( + calls.prompt[1] as { body: { parts: Array<{ text: string }> } } + ).body.parts[0]!.text; + // ONE cumulative message carries Cursor's result + its real duration. + expect(transcript).toContain("done"); + expect(transcript).toContain("5.0s"); }); it("degrades to a non-navigable card when no bridge is published", async () => { @@ -1790,24 +1800,32 @@ describe("subagent child-session linking (blocks)", () => { 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 prompt was seeded once. With `_client.request` present the + // transcript PATCHes the seed's text part — no extra prompt messages. + expect(calls.prompt.length).toBe(1); + expect( + (calls.prompt[0] as { body: { parts: Array<{ text: string }> } }).body + .parts[0]!.text, + ).toBe("pull dev"); + const bodies = partWrites.map((w) => w["body"] as Record<string, unknown>); + // The transcript lands as cumulative patches of the seed's text part — + // each one the FULL transcript so far (growing message, no fragments). + const textPatches = bodies.filter((b) => b["type"] === "text"); + expect(textPatches.length).toBeGreaterThan(0); + const lastTranscript = textPatches.at(-1)!["text"] as string; + expect(lastTranscript).toContain("working on it"); + // The subagent's own text, final answer, and activity line all merge + // into the single growing transcript. + expect(lastTranscript).toContain("subagent text"); + expect(lastTranscript).toContain("done"); + expect(lastTranscript).toContain("5.0s"); + // Every patch targets the SAME seeded part id (replace, not append). + const textIds = new Set(textPatches.map((b) => b["id"])); + expect(textIds.size).toBe(1); + // Tool activity stays out of the transcript markdown — tool parts render it. + expect(lastTranscript).not.toContain("git status"); // 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) => ({ diff --git a/test/subagent-bridge.test.ts b/test/subagent-bridge.test.ts index 4307a15..6b062ae 100644 --- a/test/subagent-bridge.test.ts +++ b/test/subagent-bridge.test.ts @@ -17,8 +17,12 @@ describe("linkSubagentSessionLive tool parts", () => { /** `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 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, @@ -88,6 +92,137 @@ describe("linkSubagentSessionLive tool parts", () => { }); }); +describe("linkSubagentSessionLive transcript flush", () => { + afterEach(() => clearSubagentBridge()); + + /** Bridge whose noReply prompt returns the seed message + its text part, + * and whose `_client.request` records PATCH calls (returns success). */ + function bridgeWithSeed() { + const request = vi.fn( + async (_options: Record<string, unknown>): Promise<unknown> => ({}), + ); + const prompt = vi.fn(async () => ({ + data: { + info: { id: "msg_seed" }, + parts: [{ id: "prt_seed", type: "text", text: "the prompt" }], + }, + })); + const create = vi.fn(async () => ({ data: { id: "ses_child" } })); + setSubagentBridge({ + client: { session: { create, prompt }, _client: { request } } as never, + directory: "/w", + }); + return { request, prompt }; + } + + it("flush grows the seed message's text part in place instead of posting new messages", async () => { + const { request, prompt } = bridgeWithSeed(); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "the prompt" }, + }); + await live?.flush("cumulative transcript v1"); + await live?.flush("cumulative transcript v2"); + // Seed prompt only — no per-flush noReply messages. + expect(prompt).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + const opts = request.mock.calls[0]![0] as Record<string, unknown>; + expect(opts["method"]).toBe("PATCH"); + expect(opts["path"]).toEqual({ + sessionID: "ses_child", + messageID: "msg_seed", + partID: "prt_seed", + }); + const body = opts["body"] as Record<string, unknown>; + expect(body).toMatchObject({ + id: "prt_seed", + messageID: "msg_seed", + sessionID: "ses_child", + type: "text", + text: "cumulative transcript v1", + }); + // Each PATCH carries the FULL transcript (replace, not append). + expect( + (request.mock.calls[1]![0]["body"] as Record<string, unknown>)["text"], + ).toBe("cumulative transcript v2"); + }); + + it("falls back to posting a new message when the seed response has no parts", async () => { + const request = vi.fn( + async (_options: Record<string, unknown>): Promise<unknown> => ({}), + ); + // Seed response deliberately omits `parts` so no text-part id is + // captured — flush must degrade to posting new messages. + const prompt = vi.fn(async (_opts?: unknown) => ({ + data: { info: { id: "msg_seed" } }, + })); + const create = vi.fn(async () => ({ data: { id: "ses_child" } })); + setSubagentBridge({ + client: { session: { create, prompt }, _client: { request } } as never, + }); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "the prompt" }, + }); + await live?.flush("fallback body"); + // No part id → no PATCH attempt; degrades to the previous behavior. + expect(request).not.toHaveBeenCalled(); + expect(prompt).toHaveBeenCalledTimes(2); + const fallbackCall = prompt.mock.calls[1]![0] as unknown as { + body: { parts: Array<{ text: string }> }; + }; + expect(fallbackCall.body.parts[0]!.text).toBe("fallback body"); + }); + + it("falls back to posting when the PATCH is rejected with an error body", async () => { + // hey-api's runtime resolves `{ error }` on a 4xx instead of rejecting. + const request = vi.fn( + async (_options: Record<string, unknown>): Promise<unknown> => ({ + error: { message: "bad" }, + }), + ); + const prompt = vi.fn(async () => ({ + data: { + info: { id: "msg_seed" }, + parts: [{ id: "prt_seed", type: "text", text: "the prompt" }], + }, + })); + const create = vi.fn(async () => ({ data: { id: "ses_child" } })); + setSubagentBridge({ + client: { session: { create, prompt }, _client: { request } } as never, + }); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "the prompt" }, + }); + await live?.flush("rejected body"); + expect(request).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(2); + // A repeat of the identical flush must not re-post the same fallback + // message (cumulative flushes supersede it). + await live?.flush("rejected body"); + expect(request).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(2); + // A later cumulative flush retries the PATCH and, while broken, posts + // once (its supersedes the stale fallback marker). + await live?.flush("rejected body + more"); + expect(request).toHaveBeenCalledTimes(2); + expect(prompt).toHaveBeenCalledTimes(3); + }); + + it("does not flush after finalize", async () => { + const { request, prompt } = bridgeWithSeed(); + const live = await linkSubagentSessionLive({ + parentSessionID: "ses_parent", + args: { description: "d", prompt: "the prompt" }, + }); + await live?.finalize(); + await live?.flush("late"); + expect(prompt).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalled(); + }); +}); + /** * Cursor returns `conversationSteps` as raw protobuf-es `toJson()` output of * `agent.v1.ConversationStep`, whose `message` oneof serializes to a single @@ -104,7 +239,9 @@ describe("renderConversationSteps (proto oneof shape)", () => { it("renders thinking text as a blockquote", () => { const out = renderConversationSteps({ - conversationSteps: [{ thinkingMessage: { text: "considering options", durationMs: 12 } }], + conversationSteps: [ + { thinkingMessage: { text: "considering options", durationMs: 12 } }, + ], }); expect(out).toBe("> considering options"); }); @@ -139,7 +276,10 @@ describe("renderConversationSteps (proto oneof shape)", () => { conversationSteps: [ { toolCall: { - shellToolCall: { args: { command: "git status" }, result: { stdout: "clean" } }, + shellToolCall: { + args: { command: "git status" }, + result: { stdout: "clean" }, + }, }, }, ], @@ -152,7 +292,11 @@ describe("renderConversationSteps (proto oneof shape)", () => { const long = "x".repeat(5000); const out = renderConversationSteps({ conversationSteps: [ - { toolCall: { shellToolCall: { args: { command: "cat big" }, result: { stdout: long } } } }, + { + toolCall: { + shellToolCall: { args: { command: "cat big" }, result: { stdout: long } }, + }, + }, ], }); expect(out).toContain(long); @@ -161,7 +305,9 @@ describe("renderConversationSteps (proto oneof shape)", () => { it("does not truncate long assistant text", () => { const long = "y".repeat(5000); - const out = renderConversationSteps({ conversationSteps: [{ assistantMessage: { text: long } }] }); + const out = renderConversationSteps({ + conversationSteps: [{ assistantMessage: { text: long } }], + }); expect(out).toBe(long); }); @@ -170,7 +316,9 @@ describe("renderConversationSteps (proto oneof shape)", () => { // 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" } } }], + conversationSteps: [ + { message: { case: "assistantMessage", value: { text: "hi there" } } }, + ], }); expect(out).toBe("hi there"); }); @@ -208,13 +356,19 @@ describe("renderConversationSteps (proto oneof shape)", () => { it("still renders the SDK's public zod shape", () => { const out = renderConversationSteps({ - conversationSteps: [{ type: "assistantMessage", message: { text: "legacy" } }], + conversationSteps: [ + { type: "assistantMessage", message: { text: "legacy" } }, + ], }); expect(out).toBe("legacy"); }); it("returns undefined when no step carries content", () => { - expect(renderConversationSteps({ conversationSteps: [{}, { assistantMessage: {} }] })).toBeUndefined(); + expect( + renderConversationSteps({ + conversationSteps: [{}, { assistantMessage: {} }], + }), + ).toBeUndefined(); }); }); @@ -253,9 +407,10 @@ describe("stampTaskPartSessionId", () => { }; it("PATCHes the running part with state.metadata.sessionId", async () => { - const request = vi.fn( - async (_opts: Record<string, unknown>) => ({ data: undefined, response: new Response() }), - ); + const request = vi.fn(async (_opts: Record<string, unknown>) => ({ + data: undefined, + response: new Response(), + })); setSubagentBridge({ client: { _client: { request }, @@ -275,15 +430,20 @@ describe("stampTaskPartSessionId", () => { 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}"); + 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> } }; + 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" }); }); @@ -298,7 +458,17 @@ describe("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 } } }, + part: { + ...runningPart, + state: { + status: "completed", + input: {}, + output: "x", + title: "t", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, childId: "ses_child", }); expect(request).not.toHaveBeenCalled(); @@ -411,9 +581,10 @@ describe("stampTaskPartSessionId", () => { 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() }), - ); + const request = vi.fn(async (_opts: Record<string, unknown>) => ({ + data: undefined, + response: new Response(), + })); setSubagentBridge({ client: { _client: { request }, @@ -467,7 +638,9 @@ describe("plugin event hook — running task stamp", () => { } as never, }); expect(request).toHaveBeenCalledTimes(1); - const opts = request.mock.calls[0]![0] as { body: { state: { metadata?: Record<string, unknown> } } }; + const opts = request.mock.calls[0]![0] as { + body: { state: { metadata?: Record<string, unknown> } }; + }; expect(opts.body.state["metadata"]).toMatchObject({ sessionId: "ses_child" }); }); diff --git a/test/subagent-stream.test.ts b/test/subagent-stream.test.ts index 63d5983..0555932 100644 --- a/test/subagent-stream.test.ts +++ b/test/subagent-stream.test.ts @@ -2,7 +2,11 @@ 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. */ +/** + * A fake live session capturing flushed transcript snapshots and tool-part + * writes. Each `flush` entry is a CUMULATIVE snapshot (the full transcript so + * far), matching the growing-message contract. + */ function fakeSession(): { session: SubagentLiveSession; flushed: string[]; @@ -54,69 +58,79 @@ function fakeSession(): { } describe("SubagentTranscriptSink", () => { - it("renders text, reasoning, and tool activity into markdown", async () => { + it("renders text and reasoning into the transcript", 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._", - ); + await sink.finalize(); 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(); + it("keeps tool activity out of the transcript — tool parts render it", async () => { + const { session, flushed, parts } = fakeSession(); const sink = new SubagentTranscriptSink(session); + sink.push({ type: "text", text: "before" }); sink.push({ type: "tool-start", id: "s1", name: "shell", - input: { command: "x".repeat(5000) }, + input: { command: "git status" }, }); sink.push({ type: "tool-result", id: "s1", name: "shell", - result: { status: "error", error: "boom" }, - isError: true, + result: { status: "success", value: { stdout: "clean" } }, + isError: false, }); + sink.push({ type: "text", text: "after" }); 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)); + expect(body).toContain("before"); + expect(body).toContain("after"); + // Tool args/results appear via `tool` parts, not markdown in the + // transcript (writing both duplicates them in the subagent pane). + expect(body).not.toContain("git status"); + expect(body).not.toContain("clean"); + expect(parts.map((p) => p.status)).toEqual(["running", "completed"]); }); - it("flushes tool results promptly and coalesces text on a timer", async () => { + it("each flush carries the FULL cumulative transcript", async () => { + vi.useFakeTimers(); + try { + const { session, flushed } = fakeSession(); + const sink = new SubagentTranscriptSink(session); + sink.push({ type: "text", text: "first fragment" }); + await vi.advanceTimersByTimeAsync(2000); + expect(flushed).toHaveLength(1); + expect(flushed[0]).toContain("first fragment"); + sink.push({ type: "text", text: " second fragment" }); + await vi.advanceTimersByTimeAsync(2000); + expect(flushed).toHaveLength(2); + // The second snapshot still carries the first — the growing message + // is replaced wholesale, never appended to piece by piece. + expect(flushed[1]).toContain("first fragment"); + expect(flushed[1]).toContain("second fragment"); + await sink.finalize(); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces text on a timer and does not flush on tool events", 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. + expect(flushed).toHaveLength(0); + // Tool events no longer force a flush — they only write tool parts, + // so a flowing paragraph is never cut at a tool boundary. sink.push({ type: "tool-result", id: "s1", @@ -124,13 +138,11 @@ describe("SubagentTranscriptSink", () => { 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"); + expect(flushed).toHaveLength(0); await vi.advanceTimersByTimeAsync(2000); - expect(flushed.join("\n")).toContain("b"); + expect(flushed).toHaveLength(1); + expect(flushed[0]).toContain("a"); + await sink.finalize(); } finally { vi.useRealTimers(); } @@ -147,28 +159,36 @@ describe("SubagentTranscriptSink", () => { expect(flushed.join("\n")).not.toContain("again"); }); - it("renders conversation steps on finalize", async () => { + it("merges the final answer, steps, and activity into ONE flush", 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" }, + sink.push({ type: "text", text: "working on it" }); + await sink.finalize( + { + resultSuffix: "final answer", + conversationSteps: [ + { assistantMessage: { text: "step text" } }, + { + toolCall: { + shellToolCall: { + args: { command: "git status" }, + result: { stdout: "clean" }, + }, }, }, - }, - ], - }); - const body = flushed.join("\n"); - expect(body).toContain("final answer"); + ], + }, + "_Subagent ran 1 step in 5.0s._", + ); + // Everything lands in a single final snapshot, not three extra messages. + expect(flushed).toHaveLength(1); + const body = flushed[0]!; expect(body).toContain("working on it"); + expect(body).toContain("final answer"); + expect(body).toContain("step text"); expect(body).toContain("git status"); expect(body).toContain("clean"); + expect(body).toContain("5.0s"); }); it("writes a running then completed tool part per nested tool call", async () => {