Skip to content

Commit 1f2600a

Browse files
fix(subagent): grow child-session transcript as one message instead of fragments (#114)
* fix(subagent): grow child-session transcript as one message instead of fragments Polling snapshots were posted as a NEW noReply user message on every flush (1.5s timer + every tool-result + up to 4 more on finalize), so a single subagent turn rendered as 5-20 fragment messages — a flowing paragraph split mid-sentence across messages. Now the seeded prompt message's text part grows in place: each flush PATCHes it via `part.update` (same endpoint the tool parts already use; opencode patches text parts in place and publishes `part.updated`, so live views re-render). Each flush carries the FULL cumulative transcript (replace, not append); identical fallback posts are deduped. Degrades to the previous per-flush new-message post only when the seed response has no parts or the PATCH fails. Tool activity drops out of the transcript markdown entirely — the child session's `tool` parts already render it live on the subagent card, so writing both duplicated it. `tool-result` no longer forces a flush, and finalize merges resultSuffix + conversationSteps + activity line into the single cumulative transcript. * chore(release): 0.9.1-next.0
1 parent 577371b commit 1f2600a

9 files changed

Lines changed: 474 additions & 180 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [0.9.1-next.0] — 2026-08-26 (pre-release)
8+
9+
Fixes the subagent child-session pane fragmenting one flowing answer
10+
into many small messages. Not on `latest`; install with
11+
`npm install @stablekernel/opencode-cursor@next` to test.
12+
13+
- **Fix: subagent pane shows one growing transcript instead of fragment
14+
messages.** Live activity snapshots were posted as a NEW message on
15+
every flush (the 1.5s timer, every tool result, plus up to four more
16+
on finalize), so a single subagent turn rendered as 5–20 fragments —
17+
a paragraph split mid-sentence across messages. The seeded prompt
18+
message's text part now grows in place: each flush PATCHes it via
19+
`part.update` with the FULL cumulative transcript (the endpoint the
20+
child session's tool parts already use; opencode publishes
21+
`part.updated`, so live views re-render). Falls back to the old
22+
per-flush message only when the seed response carries no parts or the
23+
PATCH fails. Tool activity no longer duplicates into the transcript
24+
markdown — the child session's `tool` parts already render it live on
25+
the subagent card — and `resultSuffix` + `conversationSteps` + the
26+
activity line merge into the single final transcript instead of three
27+
extra messages.
28+
729
## [0.9.0] — 2026-08-26
830

931
The Cursor agent can now use installed opencode plugins (#104), their

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stablekernel/opencode-cursor",
3-
"version": "0.9.0",
3+
"version": "0.9.1-next.0",
44
"description": "opencode provider plugin backed by the official Cursor SDK (@cursor/sdk) — adds a Cursor provider and lists its models",
55
"type": "module",
66
"license": "MIT",

src/provider/child-parts.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export function createPartID(now?: number): string {
3434
return `prt_${bytes.toString("hex")}${random}`;
3535
}
3636

37-
const PART_URL = "/session/{sessionID}/message/{messageID}/part/{partID}";
37+
export const PART_URL =
38+
"/session/{sessionID}/message/{messageID}/part/{partID}";
3839

3940
/** Arguments describing one tool call to materialise in a child session. */
4041
export interface ToolPartInput {

src/provider/subagent-bridge.ts

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { OpencodeClient } from "@opencode-ai/sdk";
2-
import { createPartID, upsertToolPart } from "./child-parts.js";
2+
import { createPartID, PART_URL, upsertToolPart } from "./child-parts.js";
33
import { pluginLog } from "./log-bridge.js";
44

55
/**
@@ -545,9 +545,12 @@ export interface SubagentLiveSession {
545545
*/
546546
messageID?: string;
547547
/**
548-
* Append a rendered markdown chunk as a noReply user message. Calls are
549-
* serialized through an internal promise chain so concurrent flushes post
550-
* in order (no interleaving).
548+
* Replace the child session's transcript with the given cumulative markdown.
549+
* Grows the seeded prompt message's text part in place via `part.update` so
550+
* the whole transcript stays ONE message (flushing new messages per snapshot
551+
* fragments it); degrades to posting a new noReply message when the part id
552+
* is unavailable or the PATCH fails. Calls are serialized through an
553+
* internal promise chain so concurrent flushes post in order.
551554
*/
552555
flush(markdown: string): Promise<void>;
553556
/**
@@ -604,40 +607,113 @@ export async function linkSubagentSessionLive(opts: {
604607
// `noReply` short-circuits before the model loop and returns the created
605608
// USER message (`session/prompt.ts:1069`), despite the generated SDK
606609
// typing it as an AssistantMessage. Its id is what child parts hang off.
610+
// The response also carries the message's parts; the text part's id is
611+
// what `flush` patches in place so the transcript stays a single message.
607612
let messageID: string | undefined;
613+
let transcriptID: string | undefined;
608614
const prompt = strField(opts.args, "prompt");
609615
if (prompt) {
610616
const seeded = await client.session.prompt({
611617
path: { id: childId },
612618
...(query ? { query } : {}),
613619
body: { noReply: true, parts: [{ type: "text", text: prompt }] },
614620
});
615-
messageID = strField(
616-
(seeded?.data as { info?: unknown } | undefined)?.info,
617-
"id",
621+
const data = seeded?.data as
622+
| { info?: unknown; parts?: unknown[] }
623+
| undefined;
624+
messageID = strField(data?.info, "id");
625+
const textPart = data?.parts?.find(
626+
(p) => isRecord(p) && p["type"] === "text",
618627
);
628+
transcriptID = strField(textPart, "id");
619629
}
620630

621631
let done = false;
622632
let chain: Promise<void> = Promise.resolve();
623-
const post = (text: string): Promise<void> => {
624-
chain = chain.then(() =>
625-
client.session
626-
.prompt({
627-
path: { id: childId },
628-
...(query ? { query } : {}),
629-
body: { noReply: true, parts: [{ type: "text", text }] },
630-
})
631-
.then(() => undefined)
632-
.catch(() => undefined),
633-
);
633+
const enqueue = (step: () => Promise<void>): Promise<void> => {
634+
chain = chain.then(step).catch(() => undefined);
634635
return chain;
635636
};
637+
const postNow = async (text: string): Promise<void> => {
638+
await client.session.prompt({
639+
path: { id: childId },
640+
...(query ? { query } : {}),
641+
body: { noReply: true, parts: [{ type: "text", text }] },
642+
});
643+
};
644+
const post = (text: string): Promise<void> => enqueue(() => postNow(text));
645+
// PATCH the seeded text part to the full cumulative transcript.
646+
// `part.update` decodes the payload as `SessionV1.Part` and patches text
647+
// parts in place, publishing `part.updated` (opencode's own streaming
648+
// does the same via updatePart+delta), so live views re-render it.
649+
const patchTranscript = async (text: string): Promise<boolean> => {
650+
if (!messageID || !transcriptID) return false;
651+
// SAFETY: the published v1 OpencodeClient type hides the hey-api runtime
652+
// client; `_client.request` exists at runtime (optional-chained below)
653+
// even though it is absent from the public types.
654+
const request = (
655+
client as unknown as {
656+
_client?: {
657+
request?: (options: Record<string, unknown>) => Promise<unknown>;
658+
};
659+
}
660+
)._client?.request;
661+
if (!request) return false;
662+
try {
663+
const res = await request({
664+
method: "PATCH",
665+
url: PART_URL,
666+
path: {
667+
sessionID: childId,
668+
messageID,
669+
partID: transcriptID,
670+
},
671+
...(query ? { query } : {}),
672+
body: {
673+
id: transcriptID,
674+
messageID,
675+
sessionID: childId,
676+
type: "text",
677+
text,
678+
},
679+
});
680+
// hey-api's runtime `request` RESOLVES `{ error }` on a 4xx instead
681+
// of rejecting, so a rejected payload looks like success unless checked.
682+
if (
683+
typeof res === "object" &&
684+
res !== null &&
685+
"error" in res &&
686+
(res as { error: unknown }).error != null
687+
)
688+
return false;
689+
return true;
690+
} catch {
691+
return false;
692+
}
693+
};
694+
695+
// The last flush whose PATCH failed and degraded to a posted message.
696+
// Cumulative flushes supersede it, so a later identical flush (or a
697+
// retry while the PATCH path is broken) must not re-post the same body.
698+
let postedFallback: string | undefined;
636699

637700
return {
638701
childId,
639702
messageID,
640-
flush: (markdown: string) => (done ? Promise.resolve() : post(markdown)),
703+
flush: (markdown: string) => {
704+
if (done) return Promise.resolve();
705+
return enqueue(async () => {
706+
if (markdown === postedFallback) return;
707+
if (await patchTranscript(markdown)) {
708+
postedFallback = undefined;
709+
return;
710+
}
711+
postedFallback = markdown;
712+
// Direct call: already running inside the chain — re-enqueueing
713+
// would self-await and deadlock.
714+
await postNow(markdown);
715+
});
716+
},
641717
toolPart: async (part) => {
642718
if (done || !messageID) return undefined;
643719
const partID = part.partID ?? createPartID();
@@ -659,6 +735,9 @@ export async function linkSubagentSessionLive(opts: {
659735
finalize: async (activity?: string) => {
660736
if (done) return;
661737
done = true;
738+
// The sink merges the activity line into its cumulative transcript;
739+
// a bare finalize (no flush after) only posts when nothing was
740+
// patched yet.
662741
if (activity) await post(activity);
663742
},
664743
};

src/provider/subagent-stream.ts

Lines changed: 34 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { SubagentNestedEvent } from "./agent-events.js";
22
import {
33
renderConversationSteps,
4-
resultText,
54
type SubagentLiveSession,
65
} from "./subagent-bridge.js";
76

@@ -24,14 +23,20 @@ function toolTitle(input: unknown): string | undefined {
2423
}
2524

2625
/**
27-
* Accumulate a Cursor subagent's nested activity (text, reasoning, tool calls)
28-
* and flush it into the linked child session in batched markdown messages.
26+
* Accumulate a Cursor subagent's nested activity (text, reasoning) and flush
27+
* it into the linked child session as a single growing transcript message.
2928
*
3029
* The opencode public API can only add user-role messages to a child session
31-
* (`session.prompt({ noReply: true })`), so the transcript renders as a
32-
* sequence of user messages. Batching keeps the session API load low while
33-
* still surfacing activity live: text deltas are coalesced on a time window,
34-
* and tool results flush promptly so tool activity appears as it happens.
30+
* (`session.prompt({ noReply: true })`), and posting each buffer snapshot as a
31+
* new message fragments a flowing paragraph across many messages. Instead the
32+
* live session grows the seeded message's text part in place (`flush` takes
33+
* the FULL cumulative transcript each time), so the child session renders as
34+
* prompt + one live-updating message. Tool activity is deliberately NOT
35+
* rendered as markdown — the TUI's subagent card already shows it live via
36+
* the `tool` parts this sink writes (`tool-start`/`tool-result`).
37+
*
38+
* Batching keeps the PATCH load low while still surfacing activity live:
39+
* text deltas are coalesced on a time window.
3540
*/
3641
export class SubagentTranscriptSink {
3742
/** Flush when this much time has elapsed since the last flush. */
@@ -40,7 +45,6 @@ export class SubagentTranscriptSink {
4045
private readonly session: SubagentLiveSession;
4146
private text = "";
4247
private reasoning = "";
43-
private readonly tools: string[] = [];
4448
private pending = false;
4549
private lastFlush = 0;
4650
private timer: ReturnType<typeof setTimeout> | undefined;
@@ -97,8 +101,9 @@ export class SubagentTranscriptSink {
97101
this.pending = true;
98102
break;
99103
case "tool-start": {
100-
this.tools.push(`**\`${event.name}\`** ${formatArgs(event.input)}`);
101-
this.pending = true;
104+
// Tool activity renders via the child session's `tool` parts, not
105+
// markdown in the transcript — writing both duplicates it in the
106+
// subagent pane.
102107
// A real `tool` part in the child session — this is what the TUI's
103108
// subagent card reads for its live `↳ <Tool> <title>` subtitle.
104109
const key = this.nestedKey(event.id);
@@ -126,8 +131,6 @@ export class SubagentTranscriptSink {
126131
break;
127132
}
128133
case "tool-result": {
129-
this.tools.push(formatResult(event.name, event.result, event.isError));
130-
this.pending = true;
131134
// Complete the matching running part. A result with no observed
132135
// start (sink attached late) still gets a completed part so the
133136
// child session reflects every call the subagent made.
@@ -146,34 +149,35 @@ export class SubagentTranscriptSink {
146149
end: Date.now(),
147150
});
148151
});
149-
// Tool results flush promptly so activity appears as it happens.
150-
this.flushNow();
151-
return;
152+
break;
152153
}
153154
}
154155
this.armTimer();
155156
}
156157

157158
/**
158-
* Flush any buffered content, then append the subagent's final answer
159-
* (`resultSuffix`), a render of its `conversationSteps` (its own
160-
* text/thinking/tool activity), and the optional activity line, and mark
161-
* the sink done. Further pushes and flushes become no-ops.
159+
* Merge the subagent's final answer (`resultSuffix`), a render of its
160+
* `conversationSteps` (its own text/thinking/tool activity), and the
161+
* optional activity line into the cumulative transcript, flush once, and
162+
* mark the sink done. Further pushes and flushes become no-ops.
162163
*/
163164
async finalize(resultValue?: unknown, activity?: string): Promise<void> {
164165
if (this.done) return;
165166
this.done = true;
166167
if (this.timer) clearTimeout(this.timer);
167168
this.timer = undefined;
168-
const body = this.render();
169-
if (body) await this.session.flush(body);
170169
const suffix =
171170
typeof resultValue === "object" && resultValue !== null
172171
? (resultValue as Record<string, unknown>)["resultSuffix"]
173172
: undefined;
174-
if (typeof suffix === "string" && suffix) await this.session.flush(suffix);
173+
if (typeof suffix === "string" && suffix) this.text += `\n\n${suffix}`;
175174
const steps = renderConversationSteps(resultValue);
176-
if (steps) await this.session.flush(steps);
175+
if (steps) this.text += `\n\n${steps}`;
176+
if (activity) this.text += `\n\n${activity}`;
177+
if (this.text.trim() || this.reasoning.trim()) {
178+
this.pending = false;
179+
await this.session.flush(this.render());
180+
}
177181
// Complete any tool calls still open — a subagent that ended without a
178182
// tool-result event would otherwise leave parts `running` forever. Must
179183
// precede session.finalize(), which closes the handle to further writes.
@@ -191,8 +195,7 @@ export class SubagentTranscriptSink {
191195
});
192196
}
193197
this.partHandles.clear();
194-
if (activity) await this.session.finalize(activity);
195-
else await this.session.finalize();
198+
await this.session.finalize();
196199
}
197200

198201
private armTimer(): void {
@@ -219,37 +222,15 @@ export class SubagentTranscriptSink {
219222
if (body) void this.session.flush(body);
220223
}
221224

222-
/** Render the accumulated activity into a single markdown message. */
225+
/**
226+
* Render the FULL cumulative transcript (everything pushed so far, plus
227+
* finalize additions). `flush` replaces the growing message's text with
228+
* this, so each flush carries the whole transcript, not just new content.
229+
*/
223230
private render(): string {
224231
const parts: string[] = [];
225232
if (this.text.trim()) parts.push(this.text.trim());
226233
if (this.reasoning.trim()) parts.push(`> ${this.reasoning.trim()}`);
227-
if (this.tools.length > 0) parts.push(this.tools.join("\n\n"));
228-
const body = parts.join("\n\n").trim();
229-
// Consume the rendered buffers so a later flush only carries new content.
230-
this.text = "";
231-
this.reasoning = "";
232-
this.tools.length = 0;
233-
return body;
234-
}
235-
}
236-
237-
/** Render a tool call's arguments as a compact inline string. */
238-
function formatArgs(input: unknown): string {
239-
let s = "";
240-
try {
241-
s = typeof input === "string" ? input : JSON.stringify(input);
242-
} catch {
243-
return "";
234+
return parts.join("\n\n").trim();
244235
}
245-
if (!s || s === "{}" || s === '""') return "";
246-
return s;
247-
}
248-
249-
/** Render a tool result as a fenced block (or an error marker). */
250-
function formatResult(name: string, result: unknown, isError: boolean): string {
251-
if (isError) return `**\`${name}\`** — _failed_`;
252-
const text = resultText(result);
253-
if (!text) return `**\`${name}\`** — _done_`;
254-
return `**\`${name}\`**\n\n\`\`\`\n${text}\n\`\`\``;
255236
}

0 commit comments

Comments
 (0)