Files
oh-my-pi/packages/ai/test/cursor-exec-modern.test.ts
T
Diogo Soares Rodrigues 0601ee7324 fix(cursor): route MCP resource frames and gate native delete
`list_mcp_resources` / `read_mcp_resource` answered as though this
client hosted no MCP servers - a hardcoded empty catalog and
`not_found`. The same session reads those resources through `mcp://`
via `MCPManager.getServerResources` / `readServerResource`, so a Cursor
model could not see resources its own session was connected to.
`CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, the
bridge answers them from the manager's live connections, and the
no-handler fallback is unchanged. A throwing lookup surfaces as an
error: an empty success claims "asked, none exist", which the model
cannot retry.

The native `delete` frame also bypassed approval. Unlike every other
frame it calls `fs.rmSync` directly rather than running a registry
tool, so no `ExtensionToolWrapper` sat in front of it, and
`allowNativeDelete` only answers whether a mutating tool was granted -
not whether the user's policy allows the call. It now resolves the
write tier against the session's approval mode and per-tool policies,
failing closed on `always-ask`, which this channel cannot prompt in.

(cherry picked from commit 44d36d1e8d35b0038d00e0202454d68fbcc53bce)
2026-07-30 01:42:28 +02:00

1424 lines
51 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
import {
type BlockState,
flushOpenToolCalls,
handleServerMessage,
processInteractionUpdate,
type ToolCallState,
} from "@oh-my-pi/pi-ai/providers/cursor";
import type { AssistantMessage, CursorExecHandlers, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { kCursorExecResolved, setStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import {
type AgentClientMessage,
AgentClientMessageSchema,
AgentServerMessageSchema,
AgentStoreConflictArgsSchema,
CanvasDiagnosticsArgsSchema,
ComputerUseArgsSchema,
ConnectScmArgsSchema,
ConnectScmErrorSchema,
ConnectScmGithubRepositorySchema,
ConnectScmGithubSchema,
ConnectScmRejectedSchema,
ConnectScmResultSchema,
ConnectScmSuccessSchema,
ConnectScmToolCallSchema,
ConversationSearchArgsSchema,
type ExecServerMessage,
ExecServerMessageSchema,
ExecuteHookArgsSchema,
ExecuteHookRequestSchema,
ForceBackgroundShellArgsSchema,
ForceBackgroundShellStatus,
ForceBackgroundSubagentArgsSchema,
ForceBackgroundSubagentStatus,
GetDiffRequestSchema,
ListMcpResourcesExecArgsSchema,
McpAllowlistPrecheckArgsSchema,
McpStateExecArgsSchema,
type McpToolDefinition,
McpToolDefinitionSchema,
PiBashExecArgsSchema,
PiEditExecArgsSchema,
PiEditReplacementSchema,
PiFindExecArgsSchema,
PiGrepExecArgsSchema,
PiLsExecArgsSchema,
PiReadExecArgsSchema,
PiWriteExecArgsSchema,
PostToolUseRequestQuerySchema,
ReadArgsSchema,
ReadMcpResourceExecArgsSchema,
RecordScreenArgsSchema,
ShellAllowlistPrecheckArgsSchema,
ShellArgsSchema,
SmartModeClassifierArgsSchema,
SubagentArgsSchema,
SubagentAwaitArgsSchema,
ToolCallSchema,
WebFetchAllowlistPrecheckArgsSchema,
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
/**
* Drive one `ExecServerMessage` through the real dispatcher and decode every
* frame it wrote back.
*
* The frames are decoded from the actual wire bytes rather than intercepted as
* objects: the whole point of these cases is what the SERVER receives, and an
* `ExecClientMessage` whose oneof never got set still looks like a populated
* object in memory.
*/
async function dispatchExec(
message: ExecServerMessage,
options: { execHandlers?: CursorExecHandlers; requestContextTools?: McpToolDefinition[] } = {},
): Promise<{ frames: AgentClientMessage[]; output: AssistantMessage; results: ToolResultMessage[] }> {
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const written: Buffer[] = [];
const h2Request = {
write: (chunk: Buffer) => {
written.push(chunk);
return true;
},
} as unknown as Parameters<typeof handleServerMessage>[5];
const results: ToolResultMessage[] = [];
await handleServerMessage(
create(AgentServerMessageSchema, { message: { case: "execServerMessage", value: message } }),
output,
stream,
state,
new Map(),
h2Request,
options.execHandlers,
result => {
results.push(result);
return result;
},
{ sawTokenDelta: false },
options.requestContextTools ?? [],
);
return { frames: written.map(decodeClientFrame), output, results };
}
/** Strip the 5-byte Connect envelope (flags + big-endian length) and decode. */
function decodeClientFrame(frame: Buffer): AgentClientMessage {
const length = frame.readUInt32BE(1);
return fromBinary(AgentClientMessageSchema, frame.subarray(5, 5 + length));
}
function buildExecMessage(message: ExecServerMessage["message"]): ExecServerMessage {
return create(ExecServerMessageSchema, { id: 7, execId: "exec-modern", message });
}
function mcpTool(name: string, providerIdentifier: string) {
return create(McpToolDefinitionSchema, { name, toolName: name, providerIdentifier, description: `${name} tool` });
}
function cursorAssistantMessage(): AssistantMessage {
return {
role: "assistant",
content: [],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
};
}
function newBlockState(overrides: Partial<BlockState> = {}): BlockState {
let textBlock: BlockState["currentTextBlock"] = null;
let thinkingBlock: BlockState["currentThinkingBlock"] = null;
let toolCall: ToolCallState | null = null;
return {
get currentTextBlock() {
return textBlock;
},
get currentThinkingBlock() {
return thinkingBlock;
},
get currentToolCall() {
return toolCall;
},
openToolCalls: new Map(),
resolvedMcpToolCallIds: new Set(),
firstTokenTime: undefined,
setTextBlock: b => {
textBlock = b;
},
setThinkingBlock: b => {
thinkingBlock = b;
},
setToolCall: t => {
toolCall = t;
},
setFirstTokenTime: () => {},
...overrides,
};
}
function toolResult(text: string, extra?: Partial<ToolResultMessage>): ToolResultMessage {
return {
role: "toolResult",
toolCallId: "unused",
toolName: "unused",
content: [{ type: "text", text }],
isError: false,
timestamp: 1,
...extra,
};
}
/** The single `execClientMessage` a dispatch produced, or a failure if it produced anything else. */
function soleResult(frames: AgentClientMessage[]) {
expect(frames).toHaveLength(1);
const frame = frames[0].message;
if (frame.case !== "execClientMessage") throw new Error(`expected execClientMessage, got ${frame.case}`);
return frame.value.message;
}
describe("Cursor stream teardown", () => {
it("keeps whole arguments on a block still open when the transport ends", async () => {
// A connect-SCM block arrives with complete `arguments` and never feeds
// the streamed partial-JSON buffer. `parseStreamingJson(undefined)`
// returns `{}`, so reparsing every open block on teardown erased exactly
// those args — the interaction then rebuilt as an empty call.
const wire = create(ToolCallSchema, {
tool: {
case: "connectScmToolCall",
value: create(ConnectScmToolCallSchema, {
args: create(ConnectScmArgsSchema, {
toolCallId: "inner-id",
target: {
case: "github",
value: create(ConnectScmGithubSchema, {
repository: create(ConnectScmGithubRepositorySchema, { owner: "can1357", repo: "oh-my-pi" }),
}),
},
}),
}),
},
});
const toolCall = fromBinary(ToolCallSchema, toBinary(ToolCallSchema, wire));
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
// Start the call and leave it open: the transport dies before completion.
processInteractionUpdate(
{ message: { case: "toolCallStarted", value: { callId: "envelope-a", toolCall } } },
output,
stream,
state,
{ sawTokenDelta: false },
);
const block = output.content.find((b): b is ToolCallState => b.type === "toolCall");
if (!block) throw new Error("expected an open tool-call block");
const argsBeforeTeardown = block.arguments;
expect(argsBeforeTeardown).not.toEqual({});
flushOpenToolCalls(output, stream, state);
expect(block.arguments).toEqual(argsBeforeTeardown);
// The block is closed, so no live card is left animating.
expect(state.openToolCalls.size).toBe(0);
expect(state.currentToolCall).toBeNull();
});
it("salvages a truncated streamed argument buffer into partial arguments", async () => {
// The other half of the same contract: blocks that *do* stream their args
// must still be reparsed on teardown, or a cut-short call renders with no
// arguments at all.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
processInteractionUpdate(
{
message: {
case: "toolCallStarted",
value: {
callId: "envelope-mcp",
toolCall: { tool: { case: "mcpToolCall", value: { args: { toolCallId: "mcp-1" } } } },
},
},
},
output,
stream,
state,
{ sawTokenDelta: false },
);
const block = output.content.find((b): b is ToolCallState => b.type === "toolCall");
if (!block) throw new Error("expected an open tool-call block");
setStreamingPartialJson(block, '{"path":"/repo/a.ts"');
flushOpenToolCalls(output, stream, state);
expect(block.arguments).toEqual({ path: "/repo/a.ts" });
});
it("pairs a server-owned call the transport cut short", async () => {
// `connect_scm` and native todo blocks are stamped `kCursorExecResolved`
// at start, so `agent-loop.ts` synthesizes no placeholder for them and
// only their completion frame pairs a result. A transport that dies
// first left the call unpaired, and `buildSessionContext` strips a
// dangling call — the whole interaction vanished from replay.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const paired: ToolResultMessage[] = [];
const state = newBlockState({ onToolResult: result => void paired.push(result) });
processInteractionUpdate(
{
message: {
case: "toolCallStarted",
value: {
callId: "envelope-todo",
toolCall: { tool: { case: "updateTodosToolCall", value: { args: { todos: [] } } } },
},
},
},
output,
stream,
state,
{ sawTokenDelta: false },
);
const block = output.content.find((b): b is ToolCallState => b.type === "toolCall");
if (!block) throw new Error("expected an open tool-call block");
expect(paired).toHaveLength(0);
flushOpenToolCalls(output, stream, state);
expect(paired).toHaveLength(1);
expect(paired[0].toolCallId).toBe(block.id);
expect(paired[0].isError).toBe(true);
});
it("leaves an exec-settled MCP call to the dispatch that owns its result", async () => {
// MCP blocks are also marked resolved, but by the exec dispatch, which
// already emitted their result and is awaited before teardown. Pairing
// one here too would file a duplicate against the same `toolCallId`.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const paired: ToolResultMessage[] = [];
const state = newBlockState({ onToolResult: result => void paired.push(result) });
state.resolvedMcpToolCallIds.add("mcp-1");
processInteractionUpdate(
{
message: {
case: "toolCallStarted",
value: {
callId: "envelope-mcp",
toolCall: { tool: { case: "mcpToolCall", value: { args: { toolCallId: "mcp-1" } } } },
},
},
},
output,
stream,
state,
{ sawTokenDelta: false },
);
flushOpenToolCalls(output, stream, state);
expect(paired).toEqual([]);
});
});
describe("Cursor modern exec frames: failure channel", () => {
it("throws on a frame whose oneof this build does not model, instead of stranding the exec id", async () => {
// A oneof number absent from agent.proto decodes into unknown fields and
// leaves `message.case` unset. The old code returned silently, so the
// server waited on a reply that never arrived.
const message = create(ExecServerMessageSchema, { id: 11, execId: "exec-unknown" });
const { frames } = await dispatchExec(message);
const control = frames[0].message;
expect(control.case).toBe("execClientControlMessage");
if (control.case !== "execClientControlMessage") throw new Error("unreachable");
expect(control.value.message.case).toBe("throw");
if (control.value.message.case !== "throw") throw new Error("unreachable");
expect(control.value.message.value.id).toBe(11);
expect(control.value.message.value.errorCode).toBe("unknown_exec_variant");
// A throw must be followed by a stream close, or the exec stays open.
const close = frames[1].message;
expect(close.case).toBe("execClientControlMessage");
if (close.case !== "execClientControlMessage") throw new Error("unreachable");
expect(close.value.message.case).toBe("streamClose");
});
it("throws for gitDiffRequest, whose response type has no error variant", async () => {
// `GetDiffResponse` models five output formats and no failure, so any
// in-band answer claims a diff was computed.
const { frames } = await dispatchExec(
buildExecMessage({
case: "gitDiffRequest",
value: create(GetDiffRequestSchema, { cwd: "/repo", ref: "HEAD" }),
}),
);
const control = frames[0].message;
if (control.case !== "execClientControlMessage") throw new Error(`got ${control.case}`);
expect(control.value.message.case).toBe("throw");
if (control.value.message.case !== "throw") throw new Error("unreachable");
expect(control.value.message.value.errorCode).toBe("exec_variant_unsupported");
});
});
describe("Cursor modern exec frames: no answer carries an unset oneof", () => {
// A result whose `result` oneof is unset is indistinguishable from "the tool
// ran and produced nothing". Every one of these frames was previously
// answered that way, or is newly handled and must not regress into it.
const cases: [name: string, message: ExecServerMessage, resultCase: string][] = [
[
"listMcpResourcesExecArgs",
buildExecMessage({
case: "listMcpResourcesExecArgs",
value: create(ListMcpResourcesExecArgsSchema, {}),
}),
"success",
],
[
"readMcpResourceExecArgs",
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { uri: "file:///nope" }),
}),
"notFound",
],
[
"recordScreenArgs",
buildExecMessage({ case: "recordScreenArgs", value: create(RecordScreenArgsSchema, {}) }),
"failure",
],
[
"computerUseArgs",
buildExecMessage({ case: "computerUseArgs", value: create(ComputerUseArgsSchema, {}) }),
"error",
],
[
"subagentArgs",
buildExecMessage({
case: "subagentArgs",
value: create(SubagentArgsSchema, { toolCallId: "c1", subagentType: "explore", prompt: "go" }),
}),
"error",
],
[
"subagentAwaitArgs",
buildExecMessage({
case: "subagentAwaitArgs",
value: create(SubagentAwaitArgsSchema, { agentId: "agent-1", timeoutMs: 10 }),
}),
"notFound",
],
[
"smartModeClassifierArgs",
buildExecMessage({
case: "smartModeClassifierArgs",
value: create(SmartModeClassifierArgsSchema, { toolCallId: "c1" }),
}),
"error",
],
[
"canvasDiagnosticsArgs",
buildExecMessage({
case: "canvasDiagnosticsArgs",
value: create(CanvasDiagnosticsArgsSchema, { path: "/a.ts", toolCallId: "c1" }),
}),
"error",
],
[
"conversationSearchArgs",
buildExecMessage({
case: "conversationSearchArgs",
value: create(ConversationSearchArgsSchema, { query: "chess", toolCallId: "c1" }),
}),
"error",
],
[
"agentStoreConflictArgs",
buildExecMessage({
case: "agentStoreConflictArgs",
value: create(AgentStoreConflictArgsSchema, {}),
}),
"error",
],
];
for (const [name, message, resultCase] of cases) {
it(`answers ${name} with a set '${resultCase}' variant`, async () => {
const { frames } = await dispatchExec(message);
const answer = soleResult(frames);
expect(answer.case).toBeDefined();
const value = answer.value;
expect(value).toHaveProperty("result");
const result = (value as { result: { case?: string } }).result;
expect(result.case).toBe(resultCase);
});
}
it("reports the requested uri back on a not-found MCP resource read", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { uri: "mcp://server/thing" }),
}),
);
const answer = soleResult(frames);
if (answer.case !== "readMcpResourceExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "notFound") throw new Error("expected notFound");
expect(answer.value.result.value.uri).toBe("mcp://server/thing");
});
it("echoes the requested path on a canvas diagnostics error", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "canvasDiagnosticsArgs",
value: create(CanvasDiagnosticsArgsSchema, { path: "/canvas/a.ts", toolCallId: "c1" }),
}),
);
const answer = soleResult(frames);
if (answer.case !== "canvasDiagnosticsResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "error") throw new Error("expected error");
expect(answer.value.result.value.path).toBe("/canvas/a.ts");
});
it("reports the awaited agent id back as not found", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "subagentAwaitArgs",
value: create(SubagentAwaitArgsSchema, { agentId: "agent-42", timeoutMs: 5 }),
}),
);
const answer = soleResult(frames);
if (answer.case !== "subagentAwaitResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "notFound") throw new Error("expected notFound");
expect(answer.value.result.value.agentId).toBe("agent-42");
});
});
describe("Cursor MCP resource frames answer from the host's servers", () => {
it("returns the resources the host advertises, with their server names", async () => {
// The empty catalog above is the no-handler fallback. A host holding live
// MCP connections must answer from them, or its resources are invisible
// to Cursor even though the same session is connected to the servers.
const { frames } = await dispatchExec(
buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }),
{
execHandlers: {
listMcpResources: async () => [
{ uri: "docs://readme", name: "README", mimeType: "text/markdown", server: "docs" },
],
},
},
);
const answer = soleResult(frames);
if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.resources).toHaveLength(1);
const [resource] = answer.value.result.value.resources;
expect(resource.uri).toBe("docs://readme");
// Cursor addresses the follow-up read by this name.
expect(resource.server).toBe("docs");
expect(resource.mimeType).toBe("text/markdown");
});
it("passes the frame's server filter through to the host", async () => {
let sawFilter: string | undefined;
await dispatchExec(
buildExecMessage({
case: "listMcpResourcesExecArgs",
value: create(ListMcpResourcesExecArgsSchema, { server: "issues" }),
}),
{
execHandlers: {
listMcpResources: async ({ server }) => {
sawFilter = server;
return [];
},
},
},
);
expect(sawFilter).toBe("issues");
});
it("answers a read with the host's text content", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
{
execHandlers: {
readMcpResource: async ({ uri }) => ({ uri, mimeType: "text/markdown", text: "# Title" }),
},
},
);
const answer = soleResult(frames);
if (answer.case !== "readMcpResourceExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.content).toEqual({ case: "text", value: "# Title" });
});
it("distinguishes a missing resource from a failing host", async () => {
// `null` is "no such server or uri", which is `not_found`. A throw is a
// real failure and must not masquerade as a missing resource — the model
// would retry a different uri instead of surfacing the fault.
const missing = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://gone" }),
}),
{ execHandlers: { readMcpResource: async () => null } },
);
const missingAnswer = soleResult(missing.frames);
if (missingAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${missingAnswer.case}`);
expect(missingAnswer.value.result.case).toBe("notFound");
const broken = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
{
execHandlers: {
readMcpResource: async () => {
throw new Error("server disconnected");
},
},
},
);
const brokenAnswer = soleResult(broken.frames);
if (brokenAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${brokenAnswer.case}`);
if (brokenAnswer.value.result.case !== "error") throw new Error(`got ${brokenAnswer.value.result.case}`);
expect(brokenAnswer.value.result.value.error).toContain("server disconnected");
});
it("reports a failing list as an error, not an empty catalog", async () => {
// An empty success says "asked, none exist" — a lie when the lookup
// failed, and one the model cannot retry.
const { frames } = await dispatchExec(
buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }),
{
execHandlers: {
listMcpResources: async () => {
throw new Error("registry unavailable");
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "error") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.error).toContain("registry unavailable");
});
});
describe("Cursor modern exec frames: status and precheck answers", () => {
it("reports NOT_FOUND for force-background requests, since nothing runs in the background", async () => {
const shell = await dispatchExec(
buildExecMessage({
case: "forceBackgroundShellArgs",
value: create(ForceBackgroundShellArgsSchema, { toolCallId: "c1" }),
}),
);
const shellAnswer = soleResult(shell.frames);
if (shellAnswer.case !== "forceBackgroundShellResult") throw new Error(`got ${shellAnswer.case}`);
expect(shellAnswer.value.status).toBe(ForceBackgroundShellStatus.NOT_FOUND);
const subagent = await dispatchExec(
buildExecMessage({
case: "forceBackgroundSubagentArgs",
value: create(ForceBackgroundSubagentArgsSchema, { toolCallId: "c1" }),
}),
);
const subagentAnswer = soleResult(subagent.frames);
if (subagentAnswer.case !== "forceBackgroundSubagentResult") throw new Error(`got ${subagentAnswer.case}`);
expect(subagentAnswer.value.status).toBe(ForceBackgroundSubagentStatus.NOT_FOUND);
});
it("declines every allowlist precheck, since no allowlist is configured", async () => {
// `true` here would grant an approval bypass that was never configured.
const shell = await dispatchExec(
buildExecMessage({
case: "shellAllowlistPrecheckArgs",
value: create(ShellAllowlistPrecheckArgsSchema, { command: "rm -rf /", workingDirectory: "/" }),
}),
);
const shellAnswer = soleResult(shell.frames);
if (shellAnswer.case !== "shellAllowlistPrecheckResult") throw new Error(`got ${shellAnswer.case}`);
expect(shellAnswer.value.allowlisted).toBe(false);
const mcp = await dispatchExec(
buildExecMessage({
case: "mcpAllowlistPrecheckArgs",
value: create(McpAllowlistPrecheckArgsSchema, { providerIdentifier: "pi-agent", toolName: "task" }),
}),
);
const mcpAnswer = soleResult(mcp.frames);
if (mcpAnswer.case !== "mcpAllowlistPrecheckResult") throw new Error(`got ${mcpAnswer.case}`);
expect(mcpAnswer.value.allowlisted).toBe(false);
const web = await dispatchExec(
buildExecMessage({
case: "webFetchAllowlistPrecheckArgs",
value: create(WebFetchAllowlistPrecheckArgsSchema, { url: "https://example.com" }),
}),
);
const webAnswer = soleResult(web.frames);
if (webAnswer.case !== "webFetchAllowlistPrecheckResult") throw new Error(`got ${webAnswer.case}`);
expect(webAnswer.value.allowlisted).toBe(false);
});
});
describe("Cursor modern exec frames: hooks", () => {
it("answers a hook query with the matching response case and an empty payload", async () => {
// The request and response oneofs are parallel; a response of the wrong
// case is read as a different hook entirely.
const { frames } = await dispatchExec(
buildExecMessage({
case: "executeHookArgs",
value: create(ExecuteHookArgsSchema, {
request: create(ExecuteHookRequestSchema, {
request: { case: "postToolUse", value: create(PostToolUseRequestQuerySchema, {}) },
}),
}),
}),
);
const answer = soleResult(frames);
if (answer.case !== "executeHookResult") throw new Error(`got ${answer.case}`);
expect(answer.value.response?.response.case).toBe("postToolUse");
if (answer.value.response?.response.case !== "postToolUse") throw new Error("unreachable");
// Every field is optional; this client runs no hooks, so it contributes none.
expect(answer.value.response.response.value.additionalContext).toBeUndefined();
});
it("maps every modelled hook request onto its parallel response case", async () => {
// The request and response oneofs are parallel by field number. A single
// mis-wired branch answers a different hook than the one asked, which the
// server reads as a stalled or misrouted hook — invisible unless every
// variant is exercised.
const requestCases = [
"preCompact",
"subagentStart",
"subagentStop",
"preToolUse",
"postToolUse",
"postToolUseFailure",
"beforeSubmitPrompt",
"afterAgentResponse",
"afterAgentThought",
"stop",
] as const;
for (const requestCase of requestCases) {
const request = create(ExecuteHookRequestSchema, {
request: { case: requestCase, value: {} },
} as never);
const { frames } = await dispatchExec(
buildExecMessage({ case: "executeHookArgs", value: create(ExecuteHookArgsSchema, { request }) }),
);
const answer = soleResult(frames);
if (answer.case !== "executeHookResult") throw new Error(`${requestCase}: got ${answer.case}`);
expect(answer.value.response?.response.case).toBe(requestCase);
}
});
it("throws for a hook request whose case this build does not model", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "executeHookArgs",
value: create(ExecuteHookArgsSchema, { request: create(ExecuteHookRequestSchema, {}) }),
}),
);
const control = frames[0].message;
if (control.case !== "execClientControlMessage") throw new Error(`got ${control.case}`);
expect(control.value.message.case).toBe("throw");
if (control.value.message.case !== "throw") throw new Error("unreachable");
expect(control.value.message.value.errorCode).toBe("unknown_hook_request");
});
});
describe("Cursor modern exec frames: MCP state", () => {
it("regroups the advertised tool catalog by provider identifier", async () => {
const { frames } = await dispatchExec(
buildExecMessage({ case: "mcpStateExecArgs", value: create(McpStateExecArgsSchema, {}) }),
{
requestContextTools: [
mcpTool("task", "pi-agent"),
mcpTool("hub", "pi-agent"),
mcpTool("mcp__fixture_report", "fixture"),
],
},
);
const answer = soleResult(frames);
if (answer.case !== "mcpStateExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
const servers = answer.value.result.value.servers;
expect(servers.map(server => server.serverIdentifier).sort()).toEqual(["fixture", "pi-agent"]);
const piAgent = servers.find(server => server.serverIdentifier === "pi-agent");
expect(piAgent?.tools.map(t => t.name)).toEqual(["task", "hub"]);
});
it("restricts the answer to the requested server identifiers", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "mcpStateExecArgs",
value: create(McpStateExecArgsSchema, { serverIdentifiers: ["fixture"] }),
}),
{ requestContextTools: [mcpTool("task", "pi-agent"), mcpTool("mcp__fixture_report", "fixture")] },
);
const answer = soleResult(frames);
if (answer.case !== "mcpStateExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.servers.map(s => s.serverIdentifier)).toEqual(["fixture"]);
});
});
describe("Cursor modern exec frames: redacted read", () => {
it("refuses redactedRead rather than serving the unredacted file", async () => {
// The frame exists so the client can strip secrets first. No redaction is
// implemented, so answering with a plain read would leak exactly what the
// frame withholds.
const readHandler: CursorExecHandlers = {
async read() {
return toolResult("SECRET=hunter2");
},
};
const { frames } = await dispatchExec(
buildExecMessage({
case: "redactedReadArgs",
value: create(ReadArgsSchema, { path: "/repo/.env", toolCallId: "c1" }),
}),
{ execHandlers: readHandler },
);
const answer = soleResult(frames);
if (answer.case !== "redactedReadResult") throw new Error(`got ${answer.case}`);
expect(answer.value.result.case).toBe("error");
if (answer.value.result.case !== "error") throw new Error("unreachable");
expect(answer.value.result.value.error).toContain("redaction");
expect(JSON.stringify(answer.value)).not.toContain("hunter2");
});
});
describe("Cursor modern exec frames: Pi tools", () => {
it("round-trips piRead through its handler into a typed success", async () => {
const { frames, output, results } = await dispatchExec(
buildExecMessage({
case: "piReadArgs",
value: create(PiReadExecArgsSchema, { path: "/repo/a.ts", offset: 5, limit: 20 }),
}),
{
execHandlers: {
async piRead(call) {
expect(call.args.path).toBe("/repo/a.ts");
expect(call.args.offset).toBe(5);
// The Pi frames carry no tool_call_id; the dispatcher mints one.
expect(call.toolCallId).toBeTruthy();
// Like the real bridge, the handler files its result under the id
// it was handed.
return toolResult("line one\nline two", { toolCallId: call.toolCallId, toolName: "read" });
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piReadResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.output).toBe("line one\nline two");
// The synthesized block and its paired result must share the minted id, or
// the call is stripped as dangling on replay.
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
expect(blocks[0].name).toBe("read");
expect(results.map(r => r.toolCallId)).toEqual([blocks[0].id]);
// The displayed args must be the operation that actually runs. The bridge
// composes offset/limit into `read`'s `:N+K` selector, so a block showing
// the bare path claims a whole-file read that never happened.
expect(blocks[0].arguments).toEqual({ path: "/repo/a.ts:5+20" });
});
it("maps a failing Pi handler onto the frame's own error variant", async () => {
const { frames } = await dispatchExec(
buildExecMessage({ case: "piReadArgs", value: create(PiReadExecArgsSchema, { path: "/nope" }) }),
{
execHandlers: {
async piRead(call) {
return toolResult("ENOENT: /nope", { isError: true, toolCallId: call.toolCallId });
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piReadResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "error") throw new Error("expected error");
expect(answer.value.result.value.error).toBe("ENOENT: /nope");
});
it("rejects a Pi frame with no handler installed instead of faking a success", async () => {
const { frames } = await dispatchExec(
buildExecMessage({ case: "piGrepArgs", value: create(PiGrepExecArgsSchema, { pattern: "foo" }) }),
);
const answer = soleResult(frames);
if (answer.case !== "piGrepResult") throw new Error(`got ${answer.case}`);
expect(answer.value.result.case).toBe("error");
});
it("carries a truncation summary onto the Pi success payload", async () => {
const { frames } = await dispatchExec(
buildExecMessage({ case: "piBashArgs", value: create(PiBashExecArgsSchema, { command: "yes" }) }),
{
execHandlers: {
async piBash() {
return toolResult("out", {
details: {
truncation: {
truncated: true,
truncatedBy: "lines",
totalLines: 5000,
outputLines: 300,
outputBytes: 4096,
},
},
});
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piBashResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.truncation?.truncated).toBe(true);
expect(answer.value.result.value.truncation?.truncatedBy).toBe("lines");
expect(answer.value.result.value.truncation?.totalLines).toBe(5000);
});
it("reads truncation from the `details.meta.truncation` shape real Bash results use", async () => {
// `BashTool` files its summary under `details.meta.truncation`
// (`TruncationMeta`), which carries no `truncated` flag — its presence is
// the signal. Reading only the top-level `TruncationResult` shape handed
// Cursor clipped output with no notice that it was clipped.
const { frames } = await dispatchExec(
buildExecMessage({ case: "piBashArgs", value: create(PiBashExecArgsSchema, { command: "yes" }) }),
{
execHandlers: {
async piBash() {
return toolResult("out", {
details: {
meta: {
truncation: {
direction: "head",
truncatedBy: "bytes",
totalLines: 5000,
totalBytes: 120000,
outputLines: 300,
outputBytes: 4096,
},
},
},
});
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piBashResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.truncation?.truncated).toBe(true);
expect(answer.value.result.value.truncation?.truncatedBy).toBe("bytes");
expect(answer.value.result.value.truncation?.totalLines).toBe(5000);
expect(answer.value.result.value.truncation?.outputBytes).toBe(4096);
});
it("omits truncation entirely when nothing was truncated", async () => {
// `optional PiTruncation` — a zeroed message would claim the output was
// trimmed to nothing.
const { frames } = await dispatchExec(
buildExecMessage({ case: "piBashArgs", value: create(PiBashExecArgsSchema, { command: "echo hi" }) }),
{
execHandlers: {
async piBash() {
return toolResult("hi", { details: { truncation: { truncated: false, totalLines: 1 } } });
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piBashResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.truncation).toBeUndefined();
});
it("passes edit replacements through to the handler and surfaces diff/patch", async () => {
const { frames, output } = await dispatchExec(
buildExecMessage({
case: "piEditArgs",
value: create(PiEditExecArgsSchema, {
path: "/repo/a.ts",
edits: [create(PiEditReplacementSchema, { oldText: "before", newText: "after" })],
}),
}),
{
execHandlers: {
async piEdit(call) {
expect(call.args.edits[0].oldText).toBe("before");
return toolResult("edited", { details: { diff: "-before\n+after", patch: "@@" } });
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "piEditResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error("expected success");
expect(answer.value.result.value.diff).toBe("-before\n+after");
expect(answer.value.result.value.patch).toBe("@@");
// The synthesized display block must use the local edit tool's snake_case
// replace schema, or the rebuilt transcript renders empty edits.
const block = output.content.find((b): b is ToolCallState => b.type === "toolCall");
expect(block?.name).toBe("edit");
expect(block?.arguments).toEqual({ path: "/repo/a.ts", edits: [{ old_text: "before", new_text: "after" }] });
});
it("answers each remaining Pi frame with a populated success payload", async () => {
// The outer result case alone is not proof: an unset inner oneof reads as
// "the tool ran and produced nothing", and dropped output or limit
// metadata is invisible at the discriminator level.
const handlers: CursorExecHandlers = {
async piWrite() {
return toolResult("wrote");
},
async piGrep() {
return toolResult("a.ts:1:hit", { details: { perFileLimitReached: 20, linesTruncated: true } });
},
async piFind() {
return toolResult("a.ts", { details: { resultLimitReached: 200 } });
},
async piLs() {
return toolResult("a.ts\nb.ts", { details: { resultLimitReached: 500 } });
},
};
const write = soleResult(
(
await dispatchExec(
buildExecMessage({
case: "piWriteArgs",
value: create(PiWriteExecArgsSchema, { path: "/a.ts", content: "x" }),
}),
{ execHandlers: handlers },
)
).frames,
);
if (write.case !== "piWriteResult") throw new Error(`got ${write.case}`);
if (write.value.result.case !== "success") throw new Error("expected success");
expect(write.value.result.value.output).toBe("wrote");
const grep = soleResult(
(
await dispatchExec(
buildExecMessage({ case: "piGrepArgs", value: create(PiGrepExecArgsSchema, { pattern: "hit" }) }),
{ execHandlers: handlers },
)
).frames,
);
if (grep.case !== "piGrepResult") throw new Error(`got ${grep.case}`);
if (grep.value.result.case !== "success") throw new Error("expected success");
expect(grep.value.result.value.output).toBe("a.ts:1:hit");
expect(grep.value.result.value.matchLimitReached).toBe(20);
expect(grep.value.result.value.linesTruncated).toBe(true);
const find = soleResult(
(
await dispatchExec(
buildExecMessage({ case: "piFindArgs", value: create(PiFindExecArgsSchema, { pattern: "*.ts" }) }),
{ execHandlers: handlers },
)
).frames,
);
if (find.case !== "piFindResult") throw new Error(`got ${find.case}`);
if (find.value.result.case !== "success") throw new Error("expected success");
expect(find.value.result.value.output).toBe("a.ts");
expect(find.value.result.value.resultLimitReached).toBe(200);
const ls = soleResult(
(
await dispatchExec(buildExecMessage({ case: "piLsArgs", value: create(PiLsExecArgsSchema, {}) }), {
execHandlers: handlers,
})
).frames,
);
if (ls.case !== "piLsResult") throw new Error(`got ${ls.case}`);
if (ls.value.result.case !== "success") throw new Error("expected success");
expect(ls.value.result.value.output).toBe("a.ts\nb.ts");
expect(ls.value.result.value.entryLimitReached).toBe(500);
});
it("serves miniSweAgentBash from the existing shell handler under its own frame", async () => {
// Frame 52 carries the same ShellArgs/ShellResult pair as `shellArgs`, so
// the shell handler answers it unchanged — under result field 55.
const { frames } = await dispatchExec(
buildExecMessage({
case: "miniSweAgentBashArgs",
value: create(ShellArgsSchema, { command: "echo hi", workingDirectory: "/repo", toolCallId: "c1" }),
}),
{
execHandlers: {
async shell(args) {
expect(args.command).toBe("echo hi");
return toolResult("hi", { toolCallId: "c1", toolName: "bash" });
},
},
},
);
const answer = soleResult(frames);
expect(answer.case).toBe("miniSweAgentBashResult");
if (answer.case !== "miniSweAgentBashResult") throw new Error("unreachable");
expect(answer.value.result.case).toBe("success");
});
});
describe("Cursor modern exec frames: server-resolved tool calls leave a paired block", () => {
// `buildSessionContext` strips a `toolCall` with no matching `toolResult`,
// taking the whole interaction out of every rebuilt transcript. Both of these
// variants are refused, but a refusal still has to settle its block.
it("pairs the conversationSearch block the exec frame synthesizes", async () => {
const { output, results } = await dispatchExec(
buildExecMessage({
case: "conversationSearchArgs",
value: create(ConversationSearchArgsSchema, { query: "chess", toolCallId: "call-search-1", limit: 5 }),
}),
);
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({ id: "call-search-1", name: "search_conversations" });
// Resolved => agent-loop emits no placeholder, so the pair must come from here.
expect(blocks[0][kCursorExecResolved]).toBe(true);
expect(results.map(result => result.toolCallId)).toEqual(["call-search-1"]);
expect(results[0].isError).toBe(true);
});
/**
* Drive a streamed `connect_scm` pair through the interaction decoder.
*
* The `ToolCall` is built with the generated schema and re-decoded from its
* own wire bytes, so the `target` / `result` oneofs carry the real
* `{ case, value }` shape the server sends — a hand-shaped literal would
* silently pass against a selector reading the wrong field.
*/
function runConnectScm(
args: { toolCallId?: string; repository?: { owner: string; repo: string } },
result: { case: "success" | "error" | "rejected"; value: Record<string, string> } | undefined,
options: { envelopeId?: string; completionCarriesCall?: boolean } = {},
): { output: AssistantMessage; results: ToolResultMessage[]; resultsAfterStart: number } {
const wire = create(ToolCallSchema, {
tool: {
case: "connectScmToolCall",
value: create(ConnectScmToolCallSchema, {
args: create(ConnectScmArgsSchema, {
toolCallId: args.toolCallId ?? "",
target: args.repository
? {
case: "github",
value: create(ConnectScmGithubSchema, {
repository: create(ConnectScmGithubRepositorySchema, args.repository),
}),
}
: { case: undefined },
}),
result: result
? create(ConnectScmResultSchema, {
result:
result.case === "success"
? { case: "success", value: create(ConnectScmSuccessSchema, {}) }
: result.case === "error"
? { case: "error", value: create(ConnectScmErrorSchema, result.value) }
: { case: "rejected", value: create(ConnectScmRejectedSchema, result.value) },
})
: undefined,
}),
},
});
const toolCall = fromBinary(ToolCallSchema, toBinary(ToolCallSchema, wire));
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const results: ToolResultMessage[] = [];
state.onToolResult = toolResultMessage => {
results.push(toolResultMessage);
return toolResultMessage;
};
const usage = { sawTokenDelta: false };
processInteractionUpdate(
{ message: { case: "toolCallStarted", value: { callId: options.envelopeId ?? "", toolCall } } },
output,
stream,
state,
usage,
);
const resultsAfterStart = results.length;
processInteractionUpdate(
{
message: {
case: "toolCallCompleted",
value: options.completionCarriesCall === false ? {} : { toolCall },
},
},
output,
stream,
state,
usage,
);
return { output, results, resultsAfterStart };
}
function soleBlock(output: AssistantMessage): ToolCallState {
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
return blocks[0];
}
it("reads the connectScm repository out of the target oneof, not a flat field", async () => {
const { output, results } = runConnectScm(
{ toolCallId: "call-scm-1", repository: { owner: "can1357", repo: "oh-my-pi" } },
{ case: "success", value: {} },
);
const block = soleBlock(output);
expect(block).toMatchObject({
id: "call-scm-1",
name: "connect_scm",
arguments: { owner: "can1357", repo: "oh-my-pi" },
});
// Resolved => agent-loop runs no local tool, so the decoder owes the pair.
expect(block[kCursorExecResolved]).toBe(true);
expect(results.map(result => result.toolCallId)).toEqual(["call-scm-1"]);
});
it("settles connectScm from the completion's result, never from the announcement", async () => {
// The start frame carries no verdict. Answering there would persist a
// fabricated outcome before the server reported one.
const success = runConnectScm({ toolCallId: "c1" }, { case: "success", value: {} });
const failed = runConnectScm({ toolCallId: "c2" }, { case: "error", value: { error: "token expired" } });
const rejected = runConnectScm({ toolCallId: "c3" }, { case: "rejected", value: { reason: "user declined" } });
// The bug this guards: settling at start persisted a hard-coded failure
// for every call, including the ones the server went on to accept.
expect([success, failed, rejected].map(run => run.resultsAfterStart)).toEqual([0, 0, 0]);
expect(success.results.map(r => r.isError)).toEqual([false]);
expect(failed.results[0].isError).toBe(true);
expect(failed.results[0].content).toEqual([{ type: "text", text: "token expired" }]);
expect(rejected.results[0].isError).toBe(true);
expect(rejected.results[0].content).toEqual([{ type: "text", text: "user declined" }]);
});
it("still pairs connectScm when the completion carries no tool call at all", async () => {
// An unpaired resolved block is stripped along with its whole interaction,
// so a resultless completion must settle as a failure rather than stay open.
const { output, results } = runConnectScm({ toolCallId: "c4" }, undefined, {
completionCarriesCall: false,
});
expect(soleBlock(output).id).toBe("c4");
expect(results.map(result => result.toolCallId)).toEqual(["c4"]);
expect(results[0].isError).toBe(true);
});
it("falls back to the envelope call id when connectScm args carry none", async () => {
const { output, results } = runConnectScm(
{ repository: { owner: "o", repo: "r" } },
{ case: "success", value: {} },
{
envelopeId: "envelope-id",
},
);
expect(soleBlock(output).id).toBe("envelope-id");
expect(results.map(result => result.toolCallId)).toEqual(["envelope-id"]);
});
it("ignores a completion whose envelope id belongs to a different call", async () => {
// Server-resolved and parallel calls interleave: a completion for an
// unrelated call would otherwise close whichever block is current and pair
// it with the wrong verdict — here, marking a pending connect as rejected.
//
// Correlation is on the envelope id, which is deliberately NOT the block
// id: the block persists under the inner `tool_call_id` the transcript
// files it under.
const wire = create(ToolCallSchema, {
tool: {
case: "connectScmToolCall",
value: create(ConnectScmToolCallSchema, {
args: create(ConnectScmArgsSchema, { toolCallId: "inner-id" }),
result: create(ConnectScmResultSchema, {
result: { case: "rejected", value: create(ConnectScmRejectedSchema, { reason: "other call" }) },
}),
}),
},
});
const toolCall = fromBinary(ToolCallSchema, toBinary(ToolCallSchema, wire));
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const results: ToolResultMessage[] = [];
state.onToolResult = result => {
results.push(result);
return result;
};
const usage = { sawTokenDelta: false };
processInteractionUpdate(
{ message: { case: "toolCallStarted", value: { callId: "envelope-a", toolCall } } },
output,
stream,
state,
usage,
);
processInteractionUpdate(
{ message: { case: "toolCallCompleted", value: { callId: "envelope-b", toolCall } } },
output,
stream,
state,
usage,
);
// The block stays open and unpaired; only its own completion settles it.
expect(results).toEqual([]);
processInteractionUpdate(
{ message: { case: "toolCallCompleted", value: { callId: "envelope-a", toolCall } } },
output,
stream,
state,
usage,
);
expect(results.map(result => result.toolCallId)).toEqual(["inner-id"]);
expect(results[0].content).toEqual([{ type: "text", text: "other call" }]);
});
it("settles two interleaved calls independently, each with its own result", async () => {
// `start A, start B, complete A, complete B` is legal wire order. With a
// single "current block" slot, B's start orphaned A: A's completion then
// settled B, and A was never paired — so the transcript rebuild dropped
// the whole interaction.
function scmCall(toolCallId: string, reason: string) {
const wire = create(ToolCallSchema, {
tool: {
case: "connectScmToolCall",
value: create(ConnectScmToolCallSchema, {
args: create(ConnectScmArgsSchema, { toolCallId }),
result: create(ConnectScmResultSchema, {
result: { case: "rejected", value: create(ConnectScmRejectedSchema, { reason }) },
}),
}),
},
});
return fromBinary(ToolCallSchema, toBinary(ToolCallSchema, wire));
}
const callA = scmCall("inner-a", "reason A");
const callB = scmCall("inner-b", "reason B");
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const results: ToolResultMessage[] = [];
state.onToolResult = result => {
results.push(result);
return result;
};
const usage = { sawTokenDelta: false };
const send = (updateCase: string, callId: string, toolCall: unknown) =>
processInteractionUpdate(
{ message: { case: updateCase, value: { callId, toolCall } } },
output,
stream,
state,
usage,
);
send("toolCallStarted", "envelope-a", callA);
send("toolCallStarted", "envelope-b", callB);
send("toolCallCompleted", "envelope-a", callA);
send("toolCallCompleted", "envelope-b", callB);
// Both blocks exist, and each is paired with its OWN verdict.
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks.map(block => block.id)).toEqual(["inner-a", "inner-b"]);
expect(results.map(result => result.toolCallId)).toEqual(["inner-a", "inner-b"]);
expect(results.map(result => result.content)).toEqual([
[{ type: "text", text: "reason A" }],
[{ type: "text", text: "reason B" }],
]);
});
it("does not double-create a block for a Pi call the exec channel already synthesized", async () => {
// Modern builds stream a `pi_*_tool_call` envelope alongside the exec
// frame. The exec side owns that block; a second one here would render the
// same call twice and leave the duplicate unpaired.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
processInteractionUpdate(
{
message: {
case: "toolCallStarted",
value: {
callId: "call-pi-read",
toolCall: { tool: { case: "piReadToolCall", value: { args: { path: "/a.ts" } } } },
},
},
},
output,
stream,
state,
{ sawTokenDelta: false },
);
expect(output.content.filter(block => block.type === "toolCall")).toHaveLength(0);
});
});