Merge remote-tracking branch 'origin/farm/c1eb4f67/cursor-persist-tool-calls'

This commit is contained in:
can1357
2026-07-02 23:27:22 +02:00
6 changed files with 416 additions and 105 deletions
+3
View File
@@ -5,6 +5,9 @@
### Changed
- Support dynamic model resolution to enable seamless mid-run model switching
### Fixed
- Fixed cursor-agent assistant messages containing native tool calls being split on text length and duplicating text blocks on replay, by emitting the assistant message as-is followed by buffered tool results and pairing them by `toolCallId` in the transcript rebuild ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)).
## [16.3.0] - 2026-07-02
+20 -104
View File
@@ -320,10 +320,9 @@ export interface AgentPromptOptions {
toolChoice?: ToolChoice;
}
/** Buffered Cursor tool result with text position at time of call */
/** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */
interface CursorToolResultEntry {
toolResult: ToolResultMessage;
textLengthAtCall: number;
}
export class Agent {
@@ -1097,12 +1096,11 @@ export class Agent {
}
} catch {}
}
// Buffer tool result with current text length for correct ordering later.
// Cursor executes tools server-side during streaming, so the assistant message
// already incorporates results. We buffer here and emit in correct order
// when the assistant message ends.
const textLength = this.#getAssistantTextLength(this.#state.streamMessage);
this.#cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength });
// Cursor executes tools server-side during streaming. We buffer
// each toolResult and emit them right after the assistant message
// closes (see `#emitCursorSplitAssistantMessage`), so replay
// receives (assistant with interleaved toolCall blocks) → results.
this.#cursorToolResultBuffer.push({ toolResult: finalMessage });
return finalMessage;
}
: undefined;
@@ -1344,115 +1342,33 @@ export class Agent {
}
}
/** Calculate total text length from an assistant message's content blocks */
#getAssistantTextLength(message: AgentMessage | null): number {
if (message?.role !== "assistant" || !Array.isArray(message.content)) {
return 0;
}
let length = 0;
for (const block of message.content) {
if (block.type === "text") {
length += (block as TextContent).text.length;
}
}
return length;
}
/**
* Emit a Cursor assistant message split around tool results.
* This fixes the ordering issue where tool results appear after the full explanation.
* Emit a Cursor assistant message with buffered exec-channel toolResults.
*
* Output order: Assistant(preamble) -> ToolResults -> Assistant(continuation)
* Since the Cursor provider now synthesizes `toolCall` content blocks at the
* point each exec tool starts (issue #4348), the assistant message content
* already interleaves text/thinking with toolCall blocks in execution order.
* We emit the message as-is and let the buffered toolResults follow — the
* transcript rebuild in `renderSessionContext` pairs them by `toolCallId`.
*
* Historical note: this used to split the assistant message at
* `textLengthAtCall` to interpose toolResults between preamble and
* continuation. That workaround existed because native cursor tools had no
* toolCall blocks; it also copied `preambleText` into every text block on
* multi-text turns, producing duplicated text on replay.
*/
#emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void {
const buffer = this.#cursorToolResultBuffer;
this.#cursorToolResultBuffer = [];
if (buffer.length === 0) {
// No tool results, emit normally
this.#state.streamMessage = null;
this.appendMessage(assistantMessage);
this.#emit({ type: "message_end", message: assistantMessage });
return;
}
// Find the split point: minimum text length at first tool call
const splitPoint = Math.min(...buffer.map(r => r.textLengthAtCall));
// Extract text content from assistant message
const content = assistantMessage.content;
let fullText = "";
for (const block of content) {
if (block.type === "text") {
fullText += block.text;
}
}
// If no text or split point is 0 or at/past end, don't split
if (fullText.length === 0 || splitPoint <= 0 || splitPoint >= fullText.length) {
// Emit assistant message first, then tool results (original behavior but with buffered results)
this.#state.streamMessage = null;
this.appendMessage(assistantMessage);
this.#emit({ type: "message_end", message: assistantMessage });
// Emit buffered tool results
for (const { toolResult } of buffer) {
this.#emit({ type: "message_start", message: toolResult });
this.appendMessage(toolResult);
this.#emit({ type: "message_end", message: toolResult });
}
return;
}
// Split the text
const preambleText = fullText.slice(0, splitPoint);
const continuationText = fullText.slice(splitPoint);
// Create preamble message (text before tools)
const preambleContent = content.map(block => {
if (block.type === "text") {
return { ...block, text: preambleText };
}
return block;
});
const preambleMessage: AssistantMessage = {
...assistantMessage,
content: preambleContent,
};
// Emit preamble
this.#state.streamMessage = null;
this.appendMessage(preambleMessage);
this.#emit({ type: "message_end", message: preambleMessage });
this.appendMessage(assistantMessage);
this.#emit({ type: "message_end", message: assistantMessage });
// Emit buffered tool results
for (const { toolResult } of buffer) {
this.#emit({ type: "message_start", message: toolResult });
this.appendMessage(toolResult);
this.#emit({ type: "message_end", message: toolResult });
}
// Emit continuation message (text after tools) if non-empty
const trimmedContinuation = continuationText.trim();
if (trimmedContinuation.length > 0) {
// Create continuation message with only text content (no thinking/toolCalls)
const continuationContent: TextContent[] = [{ type: "text", text: continuationText }];
const continuationMessage: AssistantMessage = {
...assistantMessage,
content: continuationContent,
// Zero out usage for continuation since it's part of same response
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
};
this.#emit({ type: "message_start", message: continuationMessage });
this.appendMessage(continuationMessage);
this.#emit({ type: "message_end", message: continuationMessage });
}
}
}
+3
View File
@@ -14,6 +14,9 @@
- Fixed Anthropic OAuth usage reporting to stop retrying on 429 rate-limit errors
- Fixed usage cache to correctly persist null values during cold-start failure backoff windows
### Fixed
- Fixed cursor-agent persisted transcripts losing tool-call structure by synthesizing `toolCall` content blocks for exec-channel native tools (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`), so replay pairs each tool result with its call instead of rendering header-less tool output beneath the last assistant text ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)).
## [16.3.1] - 2026-07-02
+93 -1
View File
@@ -628,7 +628,7 @@ export type ToolCallState = ToolCall & {
[kStreamingBlockIndex]: number;
[kStreamingPartialJson]?: string;
[kStreamingLastParseLen]?: number;
[kStreamingBlockKind]: "mcp" | "todo";
[kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
};
export interface BlockState {
@@ -674,6 +674,9 @@ async function handleServerMessage(
execHandlers,
onToolResult,
requestContextTools,
output,
stream,
state,
);
} else if (msgCase === "conversationCheckpointUpdate") {
handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
@@ -1030,6 +1033,9 @@ async function handleExecServerMessage(
execHandlers: CursorExecHandlers | undefined,
onToolResult: CursorToolResultHandler | undefined,
requestContextTools: McpToolDefinition[],
output: AssistantMessage,
stream: AssistantMessageEventStream,
state: BlockState,
): Promise<void> {
const execCase = execMsg.message.case;
log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
@@ -1064,6 +1070,8 @@ async function handleExecServerMessage(
switch (execCase) {
case "readArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
const { execResult } = await resolveExecHandler(
args,
execHandlers?.read?.bind(execHandlers),
@@ -1077,6 +1085,11 @@ async function handleExecServerMessage(
}
case "lsArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
// Bridge maps `ls` onto the coding-agent `read` tool (see
// `CursorExecHandlers.ls` in `pi-coding-agent/src/cursor.ts`); mirror
// that here so the synthesized block matches the toolResult's `toolName`.
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
const { execResult } = await resolveExecHandler(
args,
execHandlers?.ls?.bind(execHandlers),
@@ -1090,6 +1103,16 @@ async function handleExecServerMessage(
}
case "grepArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
// Mirror the coding-agent bridge's arg mapping so live UI (from
// `tool_execution_start`) and rebuilt transcript (from this block)
// display identical args.
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "grep", {
pattern: args.pattern,
path: searchPath,
case: args.caseInsensitive === true ? false : undefined,
});
const { execResult } = await resolveExecHandler(
args,
execHandlers?.grep?.bind(execHandlers),
@@ -1103,6 +1126,13 @@ async function handleExecServerMessage(
}
case "writeArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
// Match the bridge: prefer `fileText`, fall back to decoded `fileBytes`.
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "write", {
path: args.path,
content,
});
const { execResult } = await resolveExecHandler(
args,
execHandlers?.write?.bind(execHandlers),
@@ -1125,6 +1155,8 @@ async function handleExecServerMessage(
}
case "deleteArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "delete", { path: args.path });
const { execResult } = await resolveExecHandler(
args,
execHandlers?.delete?.bind(execHandlers),
@@ -1138,7 +1170,16 @@ async function handleExecServerMessage(
}
case "shellArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
// Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory`
// → `cwd`, drop non-positive timeouts.
const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
command: args.command,
cwd: args.workingDirectory || undefined,
timeout: shellTimeout,
});
const { execResult } = await resolveExecHandler(
args,
execHandlers?.shell?.bind(execHandlers),
@@ -1153,6 +1194,13 @@ async function handleExecServerMessage(
}
case "shellStreamArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
command: args.command,
cwd: args.workingDirectory || undefined,
timeout: shellStreamTimeout,
});
await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
return;
}
@@ -1200,6 +1248,13 @@ async function handleExecServerMessage(
}
case "diagnosticsArgs": {
const args = execMsg.message.value;
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
// Bridge maps `diagnostics` onto the coding-agent `lsp` tool with
// `action: "diagnostics"` and `file: path`.
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "lsp", {
action: "diagnostics",
file: args.path,
});
const { execResult } = await resolveExecHandler(
args,
execHandlers?.diagnostics?.bind(execHandlers),
@@ -2016,6 +2071,43 @@ function endCurrentThinkingBlock(
state.setThinkingBlock(null);
}
/**
* Synthesize a completed `toolCall` content block for a Cursor exec-channel
* native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`).
*
* Args arrive complete on the exec message, so the block opens and closes in
* one step — no partial-JSON streaming path. Without this the persisted
* assistant message carries only text/thinking blocks, and on replay the
* following `toolResult` messages have no matching `toolCall.id` in
* `renderSessionContext`, so they render as header-less `⎿` lines beneath the
* last text block instead of proper tool components (issue #4348).
*
* Exported for tests to exercise ordering with adjacent text/thinking blocks.
*/
export function synthesizeCursorExecToolCall(
output: AssistantMessage,
stream: AssistantMessageEventStream,
state: BlockState,
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
): void {
endCurrentTextBlock(output, stream, state);
endCurrentThinkingBlock(output, stream, state);
const block: ToolCallState = {
type: "toolCall",
id: toolCallId,
name: toolName,
arguments: args,
[kStreamingBlockIndex]: output.content.length,
[kStreamingBlockKind]: "cursor-exec",
};
output.content.push(block);
const idx = output.content.length - 1;
stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
}
/** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
export function processInteractionUpdate(
update: any,
@@ -3,6 +3,7 @@ import {
type BlockState,
mergeCursorMcpToolCallArgs,
processInteractionUpdate,
synthesizeCursorExecToolCall,
type ToolCallState,
type UsageState,
} from "@oh-my-pi/pi-ai/providers/cursor";
@@ -324,3 +325,81 @@ describe("processInteractionUpdate args_text_delta handling", () => {
});
});
});
describe("synthesizeCursorExecToolCall (issue #4348)", () => {
it("closes preceding text/thinking blocks before opening the synthesized toolCall", () => {
const h = newHarness();
pushTextDelta(h, "reading ");
synthesizeCursorExecToolCall(h.output, h.stream, h.state, "call-read", "read", { path: "src/foo.ts" });
expect(h.output.content.map(b => b.type)).toEqual(["text", "toolCall"]);
expect(h.output.content[0]).toMatchObject({ type: "text", text: "reading " });
expect(h.output.content[1]).toMatchObject({
type: "toolCall",
id: "call-read",
name: "read",
arguments: { path: "src/foo.ts" },
});
// text_end fires before toolcall_start so the preceding text block finalizes;
// toolcall_end fires immediately after — exec-channel args arrive complete,
// so no partial-JSON streaming is needed for the synthesized block.
expect(h.captured.map(e => e.type)).toEqual([
"text_start",
"text_delta",
"text_end",
"toolcall_start",
"toolcall_end",
]);
expect(h.state.currentTextBlock).toBeNull();
expect(h.state.currentToolCall).toBeNull();
});
it("preserves interleaving order across text ↔ tool ↔ text", () => {
const h = newHarness();
pushTextDelta(h, "planning ");
synthesizeCursorExecToolCall(h.output, h.stream, h.state, "t1", "read", { path: "a.txt" });
pushTextDelta(h, "then ");
synthesizeCursorExecToolCall(h.output, h.stream, h.state, "t2", "bash", {
command: "echo hi",
cwd: undefined,
timeout: undefined,
});
pushTextDelta(h, "done");
expect(h.output.content.map(b => b.type)).toEqual(["text", "toolCall", "text", "toolCall", "text"]);
const [t1, tc1, t2, tc2, t3] = h.output.content;
expect(t1).toMatchObject({ type: "text", text: "planning " });
expect(tc1).toMatchObject({ type: "toolCall", id: "t1", name: "read" });
expect(t2).toMatchObject({ type: "text", text: "then " });
expect(tc2).toMatchObject({
type: "toolCall",
id: "t2",
name: "bash",
arguments: { command: "echo hi", cwd: undefined, timeout: undefined },
});
expect(t3).toMatchObject({ type: "text", text: "done" });
});
it("emits toolcall events at the exact index the block occupies in content", () => {
const h = newHarness();
pushTextDelta(h, "pre");
synthesizeCursorExecToolCall(h.output, h.stream, h.state, "call-1", "grep", {
pattern: "foo",
path: ".",
case: undefined,
});
const toolStart = h.captured.find(e => e.type === "toolcall_start");
const toolEnd = h.captured.find(e => e.type === "toolcall_end");
// Text block sits at index 0; synthesized toolCall at index 1.
expect(toolStart).toMatchObject({ type: "toolcall_start", contentIndex: 1 });
expect(toolEnd).toMatchObject({
type: "toolcall_end",
contentIndex: 1,
toolCall: { id: "call-1", name: "grep" },
});
});
});
@@ -0,0 +1,218 @@
/**
* Regression for issue #4348: cursor-agent persisted transcripts lose tool-call
* structure, so replay renders header-less tool results.
*
* Before the fix, the Cursor provider only synthesized `toolCall` content
* blocks for `mcpToolCall` and `updateTodosToolCall`. Native exec-channel tools
* (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`) executed via the bridge
* and never appeared in `AssistantMessage.content`. `renderSessionContext`
* then had no matching toolCall block for each `toolResult` message, and the
* results fell through to `addMessageToChat`, rendering as bare `⎿` lines
* beneath the last assistant text.
*
* The fix (in `packages/ai/src/providers/cursor.ts` `handleExecServerMessage`)
* synthesizes a `toolCall` block on the exec channel using the same tool name
* and args the bridge emits via `tool_execution_start`. This test asserts the
* post-fix persisted shape rebuilds into proper `ToolExecutionComponent`s
* that own their tool results, not into orphan `⎿` toolResult lines.
*/
import { beforeAll, describe, expect, it, vi } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context";
import { Container } from "@oh-my-pi/pi-tui";
beforeAll(() => {
initTheme();
});
const emptyUsage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function transcriptWith(messages: AgentMessage[]): SessionContext {
return {
messages,
thinkingLevel: "off",
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
hasPersistedMCPToolSelection: false,
mode: "none",
};
}
function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContext; chatContainer: Container } {
const chatContainer = new Container();
let helpers: UiHelpers;
const ctx = {
chatContainer,
pendingMessagesContainer: new Container(),
pendingBashComponents: [],
pendingPythonComponents: [],
pendingTools: new Map(),
statusLine: { invalidate: vi.fn() },
updateEditorBorderColor: vi.fn(),
updateEditorTopBorder: vi.fn(),
ui: { requestRender: vi.fn(), imageBudget: undefined },
resetTranscript: () => chatContainer.clear(),
settings: { get: () => false },
toolOutputExpanded: false,
hideThinkingBlock: false,
focusedAgentId: undefined,
editor: { addToHistory: vi.fn() },
viewSession: {
buildTranscriptSessionContext: () => transcript,
getToolByName: () => undefined,
extensionRunner: undefined,
sessionManager: {
getEntries: vi.fn(() => []),
getCwd: vi.fn(() => "/tmp"),
},
},
sessionManager: {
getEntries: vi.fn(() => []),
getCwd: vi.fn(() => "/tmp"),
putBlobSync: vi.fn(() => ({
hash: "hash",
path: "/tmp/hash",
displayPath: "/tmp/hash.png",
ref: "blob:sha256:hash",
})),
},
addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) =>
helpers.addMessageToChat(message, options),
renderSessionContext: (
context: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => helpers.renderSessionContext(context, options),
showStatus: vi.fn(),
} as unknown as InteractiveModeContext;
helpers = new UiHelpers(ctx);
return { ctx, chatContainer };
}
/** Build the cursor-shaped assistant + toolResults message set for one turn. */
function cursorTurn(): AgentMessage[] {
// After the fix, the Cursor provider synthesizes `toolCall` blocks with the
// bridge's mapped tool names ("bash"/"read"). The stopReason for cursor
// turns with tool results is "toolUse" mid-turn — this matches how the
// agent-loop finalizes cursor exec turns.
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "text", text: "Reading and listing:" },
{ type: "toolCall", id: "tc-read", name: "read", arguments: { path: "src/foo.ts" } },
{
type: "toolCall",
id: "tc-bash",
name: "bash",
arguments: { command: "ls -1", cwd: undefined, timeout: undefined },
},
],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
usage: emptyUsage,
stopReason: "toolUse",
timestamp: 1,
};
return [
assistant,
{
role: "toolResult",
toolCallId: "tc-read",
toolName: "read",
content: [{ type: "text", text: "READ_RESULT_MARKER content of foo.ts" }],
isError: false,
timestamp: 2,
},
{
role: "toolResult",
toolCallId: "tc-bash",
toolName: "bash",
content: [{ type: "text", text: "BASH_RESULT_MARKER file1\nfile2" }],
isError: false,
timestamp: 3,
},
];
}
describe("issue #4348: cursor exec-channel tool results pair with synthesized toolCall blocks on rebuild", () => {
it("renders bash toolResult inside a ToolExecutionComponent, not as an orphan `⎿` line", async () => {
await Settings.init({ inMemory: true });
const transcript = transcriptWith(cursorTurn());
const { ctx, chatContainer } = makeRenderCtx(transcript);
new UiHelpers(ctx).renderInitialMessages();
// Component structure: an assistant message, then a bash
// ToolExecutionComponent for the synthesized bash block, then a
// ReadToolGroupComponent for the synthesized read block. Absent the
// synthesis (pre-fix), neither would exist — both results would fall
// through `addMessageToChat` (a no-op for `toolResult`) and vanish.
const rendered = Bun.stripANSI(chatContainer.render(120).join("\n"));
expect(rendered).toContain("Reading and listing:");
// Bash result is fully surfaced inside the ToolExecutionComponent —
// header carries the command, body carries the output.
expect(rendered).toContain("ls -1");
expect(rendered).toContain("BASH_RESULT_MARKER");
// Read result flows into the ReadToolGroupComponent. Its file-content
// preview is gated by the `read.toolResultPreview` setting (off in
// this harness), so we assert on the pairing signal: the read call
// appears with its path, only reachable when the toolResult attaches.
expect(rendered).toContain("Read src/foo.ts");
});
it("does not orphan the bash toolResult under the assistant when the toolCall block is missing", async () => {
// Simulates the PRE-fix persisted shape: assistant with only text (no
// toolCall blocks) + a toolResult message. The renderer has nothing to
// pair the result with. This test guards the failure mode so a future
// regression that reverts the synthesis is caught: the rendered output
// notably omits the bash command preview.
await Settings.init({ inMemory: true });
const preFixAssistant: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Running command:" }],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
usage: emptyUsage,
stopReason: "toolUse",
timestamp: 1,
};
const transcript = transcriptWith([
preFixAssistant,
{
role: "toolResult",
toolCallId: "tc-orphan",
toolName: "bash",
content: [{ type: "text", text: "ORPHAN_RESULT some output" }],
isError: false,
timestamp: 2,
},
]);
const { ctx, chatContainer } = makeRenderCtx(transcript);
new UiHelpers(ctx).renderInitialMessages();
const rendered = Bun.stripANSI(chatContainer.render(120).join("\n"));
expect(rendered).toContain("Running command:");
// Fallback path (`addMessageToChat` case "toolResult") is a no-op, so
// the result content never lands in the transcript at all. That silent
// drop is exactly what the reporter saw in the wild — every native
// cursor tool's output disappeared from replay.
expect(rendered).not.toContain("ORPHAN_RESULT");
});
});