Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/provider/child-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
117 changes: 98 additions & 19 deletions src/provider/subagent-bridge.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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<void>;
/**
Expand Down Expand Up @@ -604,40 +607,113 @@ 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({
path: { id: childId },
...(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<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),
);
const enqueue = (step: () => Promise<void>): Promise<void> => {
chain = chain.then(step).catch(() => undefined);
return chain;
};
const postNow = async (text: string): Promise<void> => {
await client.session.prompt({
path: { id: childId },
...(query ? { query } : {}),
body: { noReply: true, parts: [{ type: "text", text }] },
});
};
const post = (text: string): Promise<void> => 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<boolean> => {
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<string, unknown>) => Promise<unknown>;
};
}
)._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();
Expand All @@ -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);
},
};
Expand Down
87 changes: 34 additions & 53 deletions src/provider/subagent-stream.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { SubagentNestedEvent } from "./agent-events.js";
import {
renderConversationSteps,
resultText,
type SubagentLiveSession,
} from "./subagent-bridge.js";

Expand All @@ -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. */
Expand All @@ -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<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -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 `↳ <Tool> <title>` subtitle.
const key = this.nestedKey(event.id);
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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\`\`\``;
}
Loading