Merge remote-tracking branch 'origin/farm/c1eb4f67/cursor-persist-tool-calls'
This commit is contained in:
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user