Files
oh-my-pi/packages/ai/test/cursor-exec-handlers.test.ts
T
2026-08-20 08:03:05 +02:00

1503 lines
44 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import {
type BlockState,
buildCursorHistoryForTest,
buildCursorSystemPromptJsons,
emptyGrepPatternRejection,
handleServerMessage,
processInteractionUpdate,
resolveExecHandler,
streamCursor,
type ToolCallState,
} from "@oh-my-pi/pi-ai/providers/cursor";
import { streamCursor as lazyStreamCursor, setCursorProviderModule } from "@oh-my-pi/pi-ai/providers/register-builtins";
import type { AssistantMessage, Context, CursorExecHandlers, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import type { McpResult, ReadResult } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
import {
type AgentRunRequest,
AgentServerMessageSchema,
ExecServerMessageSchema,
McpArgsSchema,
McpResultSchema,
McpSuccessSchema,
McpTextContentSchema,
McpToolResultContentItemSchema,
ReadArgsSchema,
ReadErrorSchema,
ReadRejectedSchema,
ReadResultSchema,
ReadSuccessSchema,
} from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
import { create } from "@oh-my-pi/pi-catalog/discovery/protobuf";
import { logger } from "@oh-my-pi/pi-utils";
afterEach(() => {
vi.restoreAllMocks();
});
const cursorModel: Model<"cursor-agent"> = buildModel({
id: "cursor-composer-2.5",
name: "Cursor Composer 2.5",
api: "cursor-agent",
provider: "cursor",
baseUrl: "",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
});
const cursorMaxModeModel: Model<"cursor-agent"> = buildModel({
id: "cursor-composer-2.5-max",
name: "Cursor Composer 2.5 Max",
api: "cursor-agent",
provider: "cursor",
baseUrl: "",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
cursorMaxMode: true,
});
function cursorAssistant(
model: string,
content: AssistantMessage["content"],
timestamp: number,
stopReason: AssistantMessage["stopReason"] = "stop",
): AssistantMessage {
return {
role: "assistant",
api: "cursor-agent",
provider: "cursor",
model,
content,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp,
};
}
function captureCursorPayload(context: Context, model: Model<"cursor-agent"> = cursorModel): Promise<AgentRunRequest> {
const { promise, resolve, reject } = Promise.withResolvers<AgentRunRequest>();
streamCursor(model, context, {
apiKey: "test-token",
onPayload: payload => {
if (isAgentRunRequest(payload)) {
resolve(payload);
} else {
reject(new Error("Cursor payload was not an AgentRunRequest"));
}
throw new Error("stop after capturing Cursor payload");
},
});
return promise;
}
function isAgentRunRequest(payload: unknown): payload is AgentRunRequest {
return !!payload && typeof payload === "object" && "$typeName" in payload;
}
function toolResultContext(): Context {
return {
messages: [
{ role: "user", content: "Use the read tool.", timestamp: 1 },
{
role: "assistant",
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
content: [
{
type: "toolCall",
id: "call-read",
name: "read",
arguments: { path: "package.json" },
},
],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
{
role: "toolResult",
toolCallId: "call-read",
toolName: "read",
content: [{ type: "text", text: "package contents" }],
isError: false,
timestamp: 3,
},
],
};
}
describe("Cursor resolveExecHandler execHandlers binding", () => {
it("invokes handler with correct this when passed as bound method", async () => {
const sentinel = { tag: "bound-correctly" };
const handlers = {
sentinel,
async read(_args: { path: string }) {
// Handler methods rely on 'this' (e.g. to access other handlers or state).
// When passed without .bind(handlers), 'this' is undefined in strict mode.
return { execResult: (this as typeof handlers).sentinel, toolResult: undefined };
},
};
const { execResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
handlers.read.bind(handlers),
undefined,
() => ({}),
() => ({ tag: "rejected" }),
() => ({ tag: "error" }),
{ toolCallId: "exec-bind", toolName: "read" },
);
expect(execResult).toBe(sentinel);
expect((execResult as { tag: string }).tag).toBe("bound-correctly");
});
it("handler loses this when passed unbound and fails or returns wrong result", async () => {
const sentinel = { tag: "bound-correctly" };
const handlers = {
sentinel,
async read(_args: { path: string }) {
return { execResult: (this as typeof handlers).sentinel, toolResult: undefined };
},
};
// Pass method reference without .bind(handlers). In strict mode 'this' is undefined
// when resolveExecHandler calls handler(args), so (this as any).sentinel throws.
const { execResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
handlers.read,
undefined,
() => ({}),
() => ({ tag: "rejected" }),
(msg: string) => ({ tag: "error", message: msg }),
{ toolCallId: "exec-bind", toolName: "read" },
);
// Should get error result (handler threw accessing undefined.sentinel)
expect(execResult).toEqual({ tag: "error", message: expect.any(String) });
});
// `synthesizeCursorExecToolCall` marks every exec block `kCursorExecResolved`
// BEFORE the handler runs, so `agent-loop.ts` emits no placeholder result for
// it. Any exit that returns no `toolResult` therefore leaves the call
// unpaired and `buildSessionContext` strips the whole interaction on replay.
describe("pairs a toolResult on every result-less exit", () => {
const pairing = { toolCallId: "exec-1", toolName: "read" };
it("pairs when no handler is installed", async () => {
// The bare-SDK shape: `cursorExecHandlers` is optional, but the block
// was already synthesized and resolved by the time we get here.
const { execResult, toolResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
undefined,
undefined,
() => ({}),
(reason: string) => ({ tag: "rejected", reason }),
() => ({ tag: "error" }),
pairing,
);
expect(execResult).toEqual({ tag: "rejected", reason: "Tool not available" });
// Same text the server sees in `execResult`, so transcript and wire agree.
expect(toolResult).toMatchObject({
role: "toolResult",
toolCallId: "exec-1",
toolName: "read",
content: [{ type: "text", text: "Tool not available" }],
isError: true,
});
});
it("pairs when the handler produces nothing", async () => {
const { execResult, toolResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
async () => ({ execResult: undefined, toolResult: undefined }),
undefined,
() => ({}),
(reason: string) => ({ tag: "rejected", reason }),
() => ({ tag: "error" }),
pairing,
);
expect(execResult).toEqual({ tag: "rejected", reason: "Tool returned no result" });
expect(toolResult).toMatchObject({
toolCallId: "exec-1",
content: [{ type: "text", text: "Tool returned no result" }],
isError: true,
});
});
it("pairs when the handler throws", async () => {
const { execResult, toolResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
async () => {
throw new Error("handler blew up");
},
undefined,
() => ({}),
() => ({ tag: "rejected" }),
(message: string) => ({ tag: "error", message }),
pairing,
);
expect(execResult).toEqual({ tag: "error", message: "handler blew up" });
expect(toolResult).toMatchObject({
toolCallId: "exec-1",
content: [{ type: "text", text: "handler blew up" }],
isError: true,
});
});
it("pairs when the handler returns an execResult but no toolResult", async () => {
// The server got a real answer; only the transcript side is missing.
// Not an error — but still needs a result to keep the block paired.
const { execResult, toolResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
async () => ({ execResult: { tag: "ok" }, toolResult: undefined }),
undefined,
() => ({}),
() => ({ tag: "rejected" }),
() => ({ tag: "error" }),
pairing,
);
expect(execResult).toEqual({ tag: "ok" });
expect(toolResult).toMatchObject({ toolCallId: "exec-1", isError: false });
});
it("records a rejected TResult-only return as a failed call", async () => {
// TResult-only is a supported handler form, so the transcript entry has
// to be synthesized. A `rejected` result means Cursor was told the call
// failed - recording it as successful hides that from the user and from
// downstream lifecycle logic.
const rejected = create(ReadResultSchema, {
result: { case: "rejected", value: create(ReadRejectedSchema, { path: "/tmp/foo", reason: "denied" }) },
});
// Explicit TResult: `ReadResult` has its own `result` field, so inference
// would otherwise match the `{ result?: TResult }` handler-return variant
// and unwrap the oneof as the exec result.
const { execResult, toolResult } = await resolveExecHandler<{ path: string }, ReadResult>(
{ path: "/tmp/foo" },
async () => rejected,
undefined,
() => rejected,
() => rejected,
() => rejected,
pairing,
);
expect(execResult).toBe(rejected);
// The variant's own text is what the server received, so reuse it.
expect(toolResult).toMatchObject({
toolCallId: "exec-1",
content: [{ type: "text", text: "denied" }],
isError: true,
});
});
it("records an errored TResult-only return as a failed call", async () => {
const errored = create(ReadResultSchema, {
result: { case: "error", value: create(ReadErrorSchema, { path: "/tmp/foo", error: "EIO" }) },
});
const { toolResult } = await resolveExecHandler<{ path: string }, ReadResult>(
{ path: "/tmp/foo" },
async () => errored,
undefined,
() => errored,
() => errored,
() => errored,
pairing,
);
expect(toolResult).toMatchObject({ content: [{ type: "text", text: "EIO" }], isError: true });
});
it("keeps a successful TResult-only return successful", async () => {
// `success` is the only non-failure variant; the placeholder text still
// applies because the handler gave the transcript nothing to show.
const ok = create(ReadResultSchema, {
result: {
case: "success",
value: create(ReadSuccessSchema, { path: "/tmp/foo", output: { case: "content", value: "hi" } }),
},
});
const { toolResult } = await resolveExecHandler<{ path: string }, ReadResult>(
{ path: "/tmp/foo" },
async () => ok,
undefined,
() => ok,
() => ok,
() => ok,
pairing,
);
expect(toolResult).toMatchObject({
content: [{ type: "text", text: "Tool produced no transcript result" }],
isError: false,
});
});
it("records an MCP success carrying is_error as a failed call", async () => {
// MCP is the one shape where `success` is not enough: an application-level
// tool failure rides inside the success variant as `is_error`, mirroring
// the MCP spec. Cursor sees a failed tool, so the transcript must too.
const mcpFailure = create(McpResultSchema, {
result: {
case: "success",
value: create(McpSuccessSchema, {
content: [
create(McpToolResultContentItemSchema, {
content: { case: "text", value: create(McpTextContentSchema, { text: "upstream 503" }) },
}),
],
isError: true,
}),
},
});
const { toolResult } = await resolveExecHandler<{ name: string }, McpResult>(
{ name: "mcp__fixture" },
async () => mcpFailure,
undefined,
() => mcpFailure,
() => mcpFailure,
() => mcpFailure,
{ toolCallId: "exec-1", toolName: "mcp__fixture" },
);
// The payload's own content is the failure text — not a placeholder.
expect(toolResult).toMatchObject({
content: [{ type: "text", text: "upstream 503" }],
isError: true,
});
});
it("keeps an MCP success without is_error successful", async () => {
const mcpOk = create(McpResultSchema, {
result: {
case: "success",
value: create(McpSuccessSchema, {
content: [
create(McpToolResultContentItemSchema, {
content: { case: "text", value: create(McpTextContentSchema, { text: "all good" }) },
}),
],
isError: false,
}),
},
});
const { toolResult } = await resolveExecHandler<{ name: string }, McpResult>(
{ name: "mcp__fixture" },
async () => mcpOk,
undefined,
() => mcpOk,
() => mcpOk,
() => mcpOk,
{ toolCallId: "exec-1", toolName: "mcp__fixture" },
);
expect(toolResult).toMatchObject({ isError: false });
});
it("routes a synthesized result through onToolResult, like a real one", async () => {
const seen: string[] = [];
const { toolResult } = await resolveExecHandler(
{ path: "/tmp/foo" },
undefined,
result => {
seen.push(result.toolCallId);
return { ...result, content: [{ type: "text" as const, text: "rewritten" }] };
},
() => ({}),
() => ({ tag: "rejected" }),
() => ({ tag: "error" }),
pairing,
);
expect(seen).toEqual(["exec-1"]);
expect(toolResult).toMatchObject({ content: [{ type: "text", text: "rewritten" }] });
});
});
});
describe("Cursor system prompt encoding", () => {
it("emits one Cursor system blob per ordered prompt", () => {
const jsons = buildCursorSystemPromptJsons(["Primary instructions.", "Developer constraints."]);
expect(jsons).toHaveLength(2);
expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "Primary instructions." });
expect(JSON.parse(jsons[1])).toEqual({ role: "system", content: "Developer constraints." });
});
it("falls back to a single default system message when all entries are empty", () => {
const jsons = buildCursorSystemPromptJsons(["", ""]);
expect(jsons).toHaveLength(1);
expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "You are a helpful assistant." });
});
});
describe("Cursor request action encoding", () => {
it("uses a resume action for empty user turns", async () => {
const payload = await captureCursorPayload({
messages: [{ role: "user", content: " ", timestamp: 0 }],
});
expect(payload.action?.action.case).toBe("resumeAction");
});
it("uses a user message action for non-empty user turns", async () => {
const payload = await captureCursorPayload({
messages: [{ role: "user", content: "continue", timestamp: 0 }],
});
expect(payload.action?.action.case).toBe("userMessageAction");
});
it("sends Cursor max-mode metadata on model details and requested model", async () => {
const payload = await captureCursorPayload(
{
messages: [{ role: "user", content: "continue", timestamp: 0 }],
},
cursorMaxModeModel,
);
expect(payload.modelDetails?.maxMode).toBe(true);
expect(payload.requestedModel?.modelId).toBe("cursor-composer-2.5-max");
expect(payload.requestedModel?.maxMode).toBe(true);
});
it("sends max-mode metadata with prior history when switching providers mid-conversation", async () => {
const payload = await captureCursorPayload(
{
messages: [
{ role: "user", content: "Summarize this repo.", timestamp: 0 },
{
role: "assistant",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4.5",
content: [{ type: "text", text: "It is a monorepo." }],
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: 1,
},
{ role: "user", content: "continue", timestamp: 2 },
],
},
cursorMaxModeModel,
);
expect(payload.modelDetails?.maxMode).toBe(true);
expect(payload.requestedModel?.maxMode).toBe(true);
// History from the other provider is carried into the fresh Cursor conversation.
expect(payload.conversationState?.turns.length).toBeGreaterThan(0);
});
it("uses a resume action when a tool result is the final context message", async () => {
const payload = await captureCursorPayload(toolResultContext());
expect(payload.action?.action.case).toBe("resumeAction");
});
it("uses a user message action with selected context for image-only user turns", async () => {
const imageData = "aW1hZ2U=";
const payload = await captureCursorPayload({
messages: [
{
role: "user",
content: [{ type: "image", data: imageData, mimeType: "image/png" }],
timestamp: 0,
},
],
});
if (payload.action?.action.case !== "userMessageAction") {
throw new Error("Expected Cursor userMessageAction");
}
const userMessage = payload.action.action.value.userMessage;
expect(userMessage?.text).toBe("");
expect(userMessage?.selectedContext?.selectedImages).toHaveLength(1);
const selectedImage = userMessage?.selectedContext?.selectedImages[0];
expect(selectedImage?.mimeType).toBe("image/png");
if (selectedImage?.dataOrBlobId.case !== "data") {
throw new Error("Expected Cursor selected image data");
}
expect(Array.from(selectedImage.dataOrBlobId.value)).toEqual(Array.from(Buffer.from(imageData, "base64")));
});
});
describe("Cursor history encoding", () => {
it("keeps an empty tool result paired with its structured call", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Read the empty window.", timestamp: 1 },
cursorAssistant(
"cursor-composer-2.5",
[{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "empty.txt", limit: 0 } }],
2,
"toolUse",
),
{
role: "toolResult",
toolCallId: "call-read",
toolName: "read",
content: [{ type: "text", text: "" }],
isError: false,
timestamp: 3,
},
{ role: "user", content: "What did it say?", timestamp: 4 },
];
const history = buildCursorHistoryForTest(messages);
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Read the empty window." }] },
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "call-read", toolName: "read", args: { path: "empty.txt", limit: 0 } },
],
},
{
role: "tool",
id: "call-read",
content: [{ type: "tool-result", toolName: "read", toolCallId: "call-read", result: "" }],
},
]);
});
it("omits undefined optional tool arguments from protobuf replay", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Search for TODOs.", timestamp: 1 },
cursorAssistant(
"cursor-composer-2.5",
[
{
type: "toolCall",
id: "call-grep",
name: "grep",
arguments: {
pattern: "TODO",
path: ".",
case: undefined,
context: undefined,
limit: undefined,
},
},
],
2,
"toolUse",
),
{
role: "toolResult",
toolCallId: "call-grep",
toolName: "grep",
content: [{ type: "text", text: "No matches" }],
isError: false,
timestamp: 3,
},
{ role: "user", content: "Continue.", timestamp: 4 },
];
const history = buildCursorHistoryForTest(messages);
expect(history.rootPromptMessagesJson[1]).toEqual({
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-grep",
toolName: "grep",
args: { pattern: "TODO", path: "." },
},
],
});
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({
toolCall: expect.objectContaining({
mcpToolCall: expect.objectContaining({
args: expect.objectContaining({
args: { pattern: expect.any(String), path: expect.any(String) },
}),
}),
}),
}),
],
]);
});
it("preserves same-model K3 thinking and paired tool structure in request history", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Inspect package.json", timestamp: 1 },
cursorAssistant(
"kimi-k3-high",
[
{ type: "thinking", thinking: "I should inspect the package." },
{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "package.json" } },
],
2,
"toolUse",
),
{
role: "toolResult",
toolCallId: "call-read",
toolName: "read",
content: [{ type: "text", text: "package contents" }],
isError: false,
timestamp: 3,
},
cursorAssistant(
"kimi-k3-high",
[
{ type: "thinking", thinking: "The package is valid." },
{ type: "text", text: "Verified." },
],
4,
),
{ role: "user", content: "What did you verify?", timestamp: 5 },
];
const history = buildCursorHistoryForTest(messages, undefined, "kimi-k3-high");
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect package.json" }] },
{
role: "assistant",
content: [
{
type: "reasoning",
text: "I should inspect the package.",
providerOptions: { cursor: { modelName: "kimi-k3-high" } },
},
{
type: "tool-call",
toolCallId: "call-read",
toolName: "read",
args: { path: "package.json" },
},
],
},
{
role: "tool",
id: "call-read",
content: [
{
type: "tool-result",
toolName: "read",
toolCallId: "call-read",
result: "package contents",
},
],
},
{
role: "assistant",
content: [
{
type: "reasoning",
text: "The package is valid.",
providerOptions: { cursor: { modelName: "kimi-k3-high" } },
},
{ type: "text", text: "Verified." },
],
},
]);
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({ thinkingMessage: { text: "I should inspect the package." } }),
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-read",
mcpToolCall: expect.objectContaining({
args: expect.objectContaining({ toolCallId: "call-read", toolName: "read" }),
result: { success: { content: [{ text: { text: "package contents" } }] } },
}),
}),
}),
expect.objectContaining({ thinkingMessage: { text: "The package is valid." } }),
expect.objectContaining({ assistantMessage: { text: "Verified." } }),
],
]);
expect(buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toEqual(history);
});
it("rejects switching existing foreign history to K3", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Plan this change.", timestamp: 1 },
{
...cursorAssistant(
"claude-4.6-opus-high",
[{ type: "thinking", thinking: "Foreign signed reasoning.", thinkingSignature: "signature" }],
2,
),
api: "anthropic-messages",
provider: "anthropic",
},
{ role: "user", content: "Continue with K3.", timestamp: 3 },
];
expect(() => buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toThrow(
"cannot continue history from a different model (anthropic/claude-4.6-opus-high)",
);
});
it("replays a same-model K3 turn missing thinking instead of bricking the session", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Do a big multi-tool task", timestamp: 1 },
cursorAssistant(
"kimi-k3-high",
[{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "package.json" } }],
2,
"toolUse",
),
{
role: "toolResult",
toolCallId: "call-read",
toolName: "read",
content: [{ type: "text", text: "package contents" }],
isError: false,
timestamp: 3,
},
cursorAssistant("kimi-k3-high", [{ type: "text", text: "Done." }], 4),
{ role: "user", content: "Continue the same session", timestamp: 5 },
];
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const history = buildCursorHistoryForTest(messages, undefined, "kimi-k3-high");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("assistant turn(s) 1, 2"), {
model: "kimi-k3-high",
assistantTurns: [1, 2],
});
buildCursorHistoryForTest(messages, undefined, "kimi-k3-high");
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Do a big multi-tool task" }] },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-read", toolName: "read", args: { path: "package.json" } }],
},
{
role: "tool",
id: "call-read",
content: [{ type: "tool-result", toolName: "read", toolCallId: "call-read", result: "package contents" }],
},
{ role: "assistant", content: [{ type: "text", text: "Done." }] },
]);
});
it("keeps non-K3 Cursor thinking out of model-facing history", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Inspect package.json", timestamp: 1 },
cursorAssistant(
"cursor-composer-2.5",
[
{ type: "thinking", thinking: "Internal reasoning." },
{ type: "text", text: "Visible answer." },
],
2,
),
{ role: "user", content: "Continue.", timestamp: 3 },
];
const history = buildCursorHistoryForTest(messages, undefined, "cursor-composer-2.5");
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect package.json" }] },
{ role: "assistant", content: [{ type: "text", text: "Visible answer." }] },
]);
expect(history.turnStepMessagesJson).toEqual([
[expect.objectContaining({ assistantMessage: { text: "Visible answer." } })],
]);
});
it("keeps foreign thinking out of turns when switching to a non-K3 Cursor model", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Plan this change.", timestamp: 1 },
{
...cursorAssistant(
"claude-4.6-opus-high",
[
{ type: "thinking", thinking: "Foreign signed reasoning.", thinkingSignature: "signature" },
{ type: "text", text: "Here is the plan." },
],
2,
),
api: "anthropic-messages",
provider: "anthropic",
},
{ role: "user", content: "Continue.", timestamp: 3 },
];
const history = buildCursorHistoryForTest(messages, undefined, "cursor-composer-2.5");
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Plan this change." }] },
{ role: "assistant", content: [{ type: "text", text: "Here is the plan." }] },
]);
expect(history.turnStepMessagesJson).toEqual([
[expect.objectContaining({ assistantMessage: { text: "Here is the plan." } })],
]);
for (const steps of history.turnStepMessagesJson) {
for (const step of steps) {
expect(step).not.toHaveProperty("thinkingMessage");
}
}
});
it("preserves image-only user turns in root prompt history and conversation turns", () => {
const imageData = "aW1hZ2U=";
const history = buildCursorHistoryForTest([
{
role: "user",
content: [{ type: "image", data: imageData, mimeType: "image/png" }],
timestamp: 0,
},
{
role: "assistant",
content: [{ type: "text", text: "I can see it." }],
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,
},
{ role: "user", content: "what is in the image?", timestamp: 0 },
]);
expect(history.rootPromptMessagesJson).toEqual([
{
role: "user",
content: [{ type: "image", image: `data:image/png;base64,${imageData}`, mediaType: "image/png" }],
},
{
role: "assistant",
content: [{ type: "text", text: "I can see it." }],
},
]);
expect(history.turnUserMessagesJson).toEqual([
expect.objectContaining({
selectedContext: {
selectedImages: [
expect.objectContaining({
mimeType: "image/png",
data: imageData,
}),
],
},
}),
]);
});
it("preserves trailing tool result history for resume actions", () => {
const history = buildCursorHistoryForTest(toolResultContext().messages, -1);
expect(history.rootPromptMessagesJson).toEqual([
{
role: "user",
content: [{ type: "text", text: "Use the read tool." }],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-read",
toolName: "read",
args: { path: "package.json" },
},
],
},
{
role: "tool",
id: "call-read",
content: [
{
type: "tool-result",
toolName: "read",
toolCallId: "call-read",
result: "package contents",
},
],
},
]);
expect(history.turnUserMessagesJson).toEqual([expect.objectContaining({ text: "Use the read tool." })]);
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-read",
mcpToolCall: expect.objectContaining({
result: { success: { content: [{ text: { text: "package contents" } }] } },
}),
}),
}),
],
]);
});
it("preserves structured tool errors", () => {
const errorContext: Context = {
messages: [
{
role: "user",
content: "Search for nothing.",
timestamp: 1,
},
{
role: "assistant",
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
content: [
{
type: "toolCall",
id: "call-search",
name: "search",
arguments: { pattern: "" },
},
],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
{
role: "toolResult",
toolCallId: "call-search",
toolName: "search",
content: [{ type: "text", text: "Pattern must not be empty" }],
isError: true,
timestamp: 3,
},
],
};
const history = buildCursorHistoryForTest(errorContext.messages, -1);
expect(history.rootPromptMessagesJson).toEqual([
{
role: "user",
content: [{ type: "text", text: "Search for nothing." }],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-search",
toolName: "search",
args: { pattern: "" },
},
],
},
{
role: "tool",
id: "call-search",
content: [
{
type: "tool-result",
toolName: "search",
toolCallId: "call-search",
result: "Pattern must not be empty",
isError: true,
},
],
},
]);
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-search",
mcpToolCall: expect.objectContaining({
result: { error: { error: "Pattern must not be empty" } },
}),
}),
}),
],
]);
});
});
describe("Cursor grepArgs empty-pattern guard (issue #4574)", () => {
it("returns null when the pattern is a non-empty regex", () => {
expect(emptyGrepPatternRejection("foo", undefined)).toBeNull();
expect(emptyGrepPatternRejection("foo", "**/*.ts")).toBeNull();
// Whitespace-only patterns count as valid: leading/trailing whitespace is
// meaningful in regexes (indentation anchors), matching the coding-agent
// grep tool's own contract at packages/coding-agent/src/tools/grep.ts.
expect(emptyGrepPatternRejection(" \tfoo ", undefined)).toBeNull();
});
it("rejects an empty pattern with a glob-aware hint when only a glob is present", () => {
const message = emptyGrepPatternRejection("", "**/*snapcompact*");
expect(message).toContain("grep pattern is required");
expect(message).toContain('"**/*snapcompact*"');
expect(message).toContain("ls/read tool");
});
it("rejects an empty pattern with a plain message when no glob is present", () => {
expect(emptyGrepPatternRejection("", undefined)).toBe("grep pattern is required (received an empty pattern).");
expect(emptyGrepPatternRejection(undefined, undefined)).toBe(
"grep pattern is required (received an empty pattern).",
);
});
it("rejects a whitespace-only pattern the same way as an empty one", () => {
expect(emptyGrepPatternRejection(" ", undefined)).toBe("grep pattern is required (received an empty pattern).");
expect(emptyGrepPatternRejection("\t\n", "src/**/*.ts")).toContain('"src/**/*.ts"');
});
});
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(): 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: () => {},
};
}
describe("Cursor K3 completion warnings", () => {
it("warns when a K3 turn ends without thinking blocks", () => {
const output = cursorAssistantMessage();
output.model = "kimi-k3-high";
output.timestamp = 7516;
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
processInteractionUpdate(
{ message: { case: "turnEnded", value: {} } },
output,
new AssistantMessageEventStream(),
newBlockState(),
{ sawTokenDelta: false },
);
expect(warnSpy).toHaveBeenCalledWith(
"Cursor kimi-k3 turn completed without thinking blocks; persisted history will replay this turn without reasoning",
{ model: "kimi-k3-high", messageTimestamp: 7516 },
);
});
});
describe("Cursor exec local-work tracking (issue #4593)", () => {
it("marks the stream busy for the duration of a local exec handler", async () => {
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const written: unknown[] = [];
const h2Request = {
write: (chunk: unknown) => {
written.push(chunk);
return true;
},
} as unknown as Parameters<typeof handleServerMessage>[5];
const handlerGate = Promise.withResolvers<void>();
const execHandlers: CursorExecHandlers = {
async read(args) {
await handlerGate.promise;
return {
role: "toolResult",
toolCallId: args.toolCallId,
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
timestamp: 1,
} satisfies ToolResultMessage;
},
};
const serverMsg = create(AgentServerMessageSchema, {
message: {
case: "execServerMessage",
value: create(ExecServerMessageSchema, {
id: 1,
execId: "exec-1",
message: {
case: "readArgs",
value: create(ReadArgsSchema, { path: "/tmp/slow-file", toolCallId: "call-read-1" }),
},
}),
},
});
expect(stream.hasPendingLocalWork).toBe(false);
const dispatch = handleServerMessage(
serverMsg,
output,
stream,
state,
new Map(),
h2Request,
execHandlers,
undefined,
{ sawTokenDelta: false },
[],
);
// The exec round-trip is in flight: the stream must advertise local
// work so the lazy idle watchdog defers instead of aborting.
expect(stream.hasPendingLocalWork).toBe(true);
handlerGate.resolve();
await dispatch;
expect(stream.hasPendingLocalWork).toBe(false);
// The read result went back out on the exec channel.
expect(written.length).toBe(1);
});
it("synthesizes an MCP call when the exec frame precedes its streamed block", async () => {
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const h2Request = { write: () => true } as unknown as Parameters<typeof handleServerMessage>[5];
const serverMsg = create(AgentServerMessageSchema, {
message: {
case: "execServerMessage",
value: create(ExecServerMessageSchema, {
id: 1,
execId: "exec-mcp-1",
message: {
case: "mcpArgs",
value: create(McpArgsSchema, {
name: "mcp__fixture_report",
toolName: "mcp__fixture_report",
toolCallId: "call-mcp-1",
providerIdentifier: "pi-agent",
args: { query: new TextEncoder().encode(JSON.stringify("latest chess news")) },
}),
},
}),
},
});
const execHandlers: CursorExecHandlers = {
async mcp(args) {
return {
role: "toolResult",
toolCallId: args.toolCallId,
toolName: args.toolName,
content: [{ type: "text", text: "reported" }],
isError: false,
timestamp: 1,
};
},
};
await handleServerMessage(
serverMsg,
output,
stream,
state,
new Map(),
h2Request,
execHandlers,
undefined,
{
sawTokenDelta: false,
},
[],
);
processInteractionUpdate(
{ message: { case: "textDelta", value: { text: "Final synthesized answer" } } },
output,
stream,
state,
{ sawTokenDelta: false },
);
expect(output.content).toHaveLength(2);
expect(output.content[0]).toMatchObject({
type: "toolCall",
id: "call-mcp-1",
name: "mcp__fixture_report",
arguments: { query: "latest chess news" },
});
expect(output.content[1]).toMatchObject({ type: "text", text: "Final synthesized answer" });
expect(state.resolvedMcpToolCallIds.has("call-mcp-1")).toBe(true);
});
it("leaves an MCP call unpaired when no mcp handler is installed", async () => {
// The exception to the pairing rule. Every other exec case synthesizes a
// block and marks it resolved unconditionally, but `mcpArgs` only marks it
// when an `mcp` handler exists. Without one the streamed block stays
// unresolved, so `agent-loop.ts` runs it locally and pairs its own result
// — synthesizing one here would double up.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const h2Request = { write: () => true } as unknown as Parameters<typeof handleServerMessage>[5];
const serverMsg = create(AgentServerMessageSchema, {
message: {
case: "execServerMessage",
value: create(ExecServerMessageSchema, {
id: 1,
execId: "exec-mcp-2",
message: {
case: "mcpArgs",
value: create(McpArgsSchema, {
name: "mcp__fixture_report",
toolName: "mcp__fixture_report",
toolCallId: "call-mcp-unhandled",
providerIdentifier: "pi-agent",
}),
},
}),
},
});
const collected: ToolResultMessage[] = [];
await handleServerMessage(
serverMsg,
output,
stream,
state,
new Map(),
h2Request,
undefined,
result => {
collected.push(result);
return result;
},
{ sawTokenDelta: false },
[],
);
expect(state.resolvedMcpToolCallIds.has("call-mcp-unhandled")).toBe(false);
expect(collected).toEqual([]);
// The block itself only exists once the streamed call arrives. It must
// come out unresolved, so `agent-loop.ts` executes it and pairs a result.
processInteractionUpdate(
{
message: {
case: "toolCallStarted",
value: {
callId: "call-mcp-unhandled",
toolCall: {
mcpToolCall: {
args: {
name: "mcp__fixture_report",
toolName: "mcp__fixture_report",
toolCallId: "call-mcp-unhandled",
},
},
},
},
},
},
output,
stream,
state,
{ sawTokenDelta: false },
);
const mcpBlock = output.content.find(
(block): block is ToolCallState => block.type === "toolCall" && block.id === "call-mcp-unhandled",
);
expect(mcpBlock).toBeDefined();
expect(mcpBlock?.[kCursorExecResolved]).toBeUndefined();
});
it("pairs a result for a synthesized exec block when no handler is installed", async () => {
// End-to-end over the real dispatch: synthesis, resolved marking and
// pairing must line up. `cursorExecHandlers` is optional (a bare SDK host
// passes none), but the block is synthesized and marked
// `kCursorExecResolved` regardless, so `agent-loop.ts` emits no
// placeholder for it. Production callsites discard the returned
// `toolResult` — the sink is the only path that reaches the transcript,
// so an unpaired call here is stripped from every rebuild.
const output = cursorAssistantMessage();
const stream = new AssistantMessageEventStream();
const state = newBlockState();
const h2Request = { write: () => true } as unknown as Parameters<typeof handleServerMessage>[5];
const serverMsg = create(AgentServerMessageSchema, {
message: {
case: "execServerMessage",
value: create(ExecServerMessageSchema, {
id: 1,
execId: "exec-read-1",
message: {
case: "readArgs",
value: create(ReadArgsSchema, { path: "/tmp/orphan", toolCallId: "call-read-orphan" }),
},
}),
},
});
const collected: ToolResultMessage[] = [];
await handleServerMessage(
serverMsg,
output,
stream,
state,
new Map(),
h2Request,
undefined,
result => {
collected.push(result);
return result;
},
{ sawTokenDelta: false },
[],
);
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({ id: "call-read-orphan", name: "read" });
// Resolved => no placeholder from agent-loop, so the sink must have fired.
expect(blocks[0][kCursorExecResolved]).toBe(true);
expect(collected.map(result => result.toolCallId)).toEqual(["call-read-orphan"]);
expect(collected[0]).toMatchObject({ toolName: "read", isError: true });
});
it("survives a local exec tool outliving the lazy idle budget end to end", async () => {
const workDone = Promise.withResolvers<void>();
// The tracked work completes only once the lazy watchdog has consulted
// the stream's local-work state at two expired deadlines, proving the
// idle budget was truly exceeded while the exec tool ran.
class ProbedStream extends AssistantMessageEventStream {
probeCalls = 0;
override get hasPendingLocalWork(): boolean {
this.probeCalls++;
if (this.probeCalls >= 2) workDone.resolve();
return super.hasPendingLocalWork;
}
}
const source = new ProbedStream();
let providerSignal: AbortSignal | undefined;
setCursorProviderModule({
streamCursor: (_model, _context, options) => {
providerSignal = options.signal;
void (async () => {
const partial = cursorAssistantMessage();
source.push({ type: "start", partial });
source.push({ type: "text_delta", contentIndex: 0, delta: "spawning local tool", partial });
await source.trackLocalWork(workDone.promise);
const message = cursorAssistantMessage();
source.push({ type: "done", reason: "stop", message });
})();
return source;
},
});
const stream = lazyStreamCursor(cursorModel, { messages: [] }, { apiKey: "test", streamIdleTimeoutMs: 5 });
const result = await stream.result();
expect(providerSignal?.aborted).toBe(false);
expect(source.probeCalls).toBeGreaterThanOrEqual(2);
expect(result.stopReason).toBe("stop");
});
it("still aborts a silent cursor stream with no local work in flight", async () => {
const partial = cursorAssistantMessage();
let providerSignal: AbortSignal | undefined;
const source = {
async *[Symbol.asyncIterator]() {
yield { type: "start", partial } as const;
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial } as const;
const stalled = Promise.withResolvers<never>();
if (providerSignal?.aborted) {
stalled.reject(new Error("Request was aborted"));
}
providerSignal?.addEventListener("abort", () => stalled.reject(new Error("Request was aborted")), {
once: true,
});
await stalled.promise;
},
} as unknown as AssistantMessageEventStream;
setCursorProviderModule({
streamCursor: (_model, _context, options) => {
providerSignal = options.signal;
return source;
},
});
const stream = lazyStreamCursor(cursorModel, { messages: [] }, { apiKey: "test", streamIdleTimeoutMs: 10 });
const result = await stream.result();
expect(providerSignal?.aborted).toBe(true);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
});
});