diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 1a5ae4ec8e11..3941cdb3e736 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -64,8 +64,11 @@ import { promisify } from "node:util"; import { toolChoiceDirectiveLine, buildCursorOutputConstraints } from "./cursor/prompt.ts"; import { bridgeCursorBuiltinTool, + bridgeCursorNativeTodoWrite, + extractLatestTodoHistory, selectCursorBridgeTools, type CursorClientPlatform, + type CursorTodoHistoryItem, } from "./cursor/builtinToolBridge.ts"; import { isComposerModel, @@ -463,6 +466,7 @@ export function processFrame( mcpTools?: McpToolDefinition[]; blobStore?: Map; clientPlatform?: CursorClientPlatform; + todoHistory?: CursorTodoHistoryItem[]; } = {} ): void { // 1. JSON error envelope (Connect-RPC style — usually status > 200). @@ -573,7 +577,18 @@ export function processFrame( return; } for (const d of deltas) { - if (d.kind === "text" && d.text) { + if (d.kind === "native_todo_write") { + const dedupKey = `native_todo_write:${d.toolCallId}`; + if (!ackedExecIds.has(dedupKey)) { + ackedExecIds.add(dedupKey); + const bridge = bridgeCursorNativeTodoWrite(d, opts.mcpTools ?? [], opts.todoHistory); + if (bridge) { + emitStructuredToolCall(ctx, bridge.toolName, bridge.arguments); + ctx.requiresColdResume = true; + ctx.endReason = "tool_calls"; + } + } + } else if (d.kind === "text" && d.text) { if (!ctx.emittedRoleChunk) { emitChunk(ctx, { role: "assistant", content: "" }); ctx.emittedRoleChunk = true; @@ -987,6 +1002,7 @@ export class CursorExecutor extends BaseExecutor { mcpTools: McpToolDefinition[] | undefined, blobStore: Map | undefined, clientPlatform: CursorClientPlatform | undefined, + todoHistory: CursorTodoHistoryItem[] | undefined, signal?: AbortSignal ): Promise { const ackedExecIds = new Set(); @@ -1090,6 +1106,7 @@ export class CursorExecutor extends BaseExecutor { mcpTools, blobStore, clientPlatform, + todoHistory, }); } catch (err) { debugLog( @@ -1148,6 +1165,7 @@ export class CursorExecutor extends BaseExecutor { : undefined; const mcpTools = selectCursorBridgeTools(declaredMcpTools, body.tool_choice); const clientPlatform = inferCursorClientPlatform(messages); + const todoHistory = extractLatestTodoHistory(messages); // Sanitize error messages: strip stack traces and absolute paths to // prevent information exposure. Shared helper in utils/error.ts. @@ -1314,7 +1332,7 @@ export class CursorExecutor extends BaseExecutor { start: async (controller) => { const ctx = newStreamCtx(model, (s) => controller.enqueue(enc.encode(s))); try { - await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, signal); + await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, todoHistory, signal); this.finalizeSseStream(ctx, body); finishLifecycle(ctx, false); controller.close(); @@ -1344,7 +1362,7 @@ export class CursorExecutor extends BaseExecutor { // Non-streaming: drive to completion, return chat.completion JSON. const ctx = newStreamCtx(model, () => {}); try { - await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, signal); + await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, todoHistory, signal); } catch (err) { finishLifecycle(ctx, true); const message = err instanceof Error ? err.message : String(err); diff --git a/open-sse/executors/cursor/builtinToolBridge.ts b/open-sse/executors/cursor/builtinToolBridge.ts index 121d80fd8c6e..537783b52f70 100644 --- a/open-sse/executors/cursor/builtinToolBridge.ts +++ b/open-sse/executors/cursor/builtinToolBridge.ts @@ -1,5 +1,7 @@ import { decodeProtobufValue, + type ChatMessage, + type DecodedDelta, type ExecServerEvent, type McpToolDefinition, } from "../../utils/cursorAgentProtobuf.ts"; @@ -11,6 +13,13 @@ export type CursorBuiltinToolBridge = { export type CursorClientPlatform = "windows" | "posix"; +export type CursorTodoHistoryItem = { + content: string; + priority?: string; +}; + +type CursorNativeTodoWrite = Extract; + type OpenAIToolChoice = string | { type?: unknown; function?: { name?: unknown } } | null | undefined; @@ -22,6 +31,7 @@ type JsonSchema = { }; const DIRECT_SHELL_TOOL_NAMES = ["bash", "shell", "run_terminal_cmd"]; +const TODO_WRITE_TOOL_NAMES = ["todowrite", "todo_write"]; const BRIDGE_DESCRIPTION = "Run Cursor-requested shell command"; const ROOT_SCHEMA_KEYS = new Set([ "$schema", @@ -46,6 +56,7 @@ const PROPERTY_ANNOTATION_KEYS = [ ] as const; const SCALAR_PROPERTY_KEYS = new Set(["type", ...PROPERTY_ANNOTATION_KEYS]); const ARRAY_PROPERTY_KEYS = new Set(["type", "items", ...PROPERTY_ANNOTATION_KEYS]); +const TODO_SCALAR_PROPERTY_KEYS = new Set(["type", "enum", ...PROPERTY_ANNOTATION_KEYS]); /** Restrict bridge candidates to the caller's OpenAI tool_choice contract. */ export function selectCursorBridgeTools( @@ -53,14 +64,24 @@ export function selectCursorBridgeTools( toolChoice: OpenAIToolChoice ): McpToolDefinition[] | undefined { if (toolChoice === "none") return undefined; - const specificName = - toolChoice && - typeof toolChoice === "object" && - toolChoice.type === "function" && - typeof toolChoice.function?.name === "string" - ? toolChoice.function.name - : undefined; - return specificName ? tools?.filter((tool) => tool.name === specificName) : tools; + if ( + toolChoice === undefined || + toolChoice === null || + toolChoice === "auto" || + toolChoice === "required" + ) { + return tools; + } + if ( + !isRecord(toolChoice) || + toolChoice.type !== "function" || + !isRecord(toolChoice.function) || + typeof toolChoice.function.name !== "string" || + !toolChoice.function.name + ) { + return undefined; + } + return tools?.filter((tool) => tool.name === toolChoice.function.name); } function isRecord(value: unknown): value is Record { @@ -73,29 +94,28 @@ function hasOnlyKeys(value: Record, allowed: Set): bool function schemaFor(tool: McpToolDefinition): JsonSchema | null { try { - const decoded = decodeProtobufValue(tool.inputSchemaBytes); - if (!isRecord(decoded) || decoded.type !== "object") return null; - if (!hasOnlyKeys(decoded, ROOT_SCHEMA_KEYS)) return null; - if (decoded.properties !== undefined && !isRecord(decoded.properties)) return null; - if ( - decoded.required !== undefined && - (!Array.isArray(decoded.required) || - !decoded.required.every((key) => typeof key === "string")) - ) { - return null; - } - if ( - decoded.additionalProperties !== undefined && - typeof decoded.additionalProperties !== "boolean" - ) { - return null; - } - return decoded as JsonSchema; + return objectSchema(decodeProtobufValue(tool.inputSchemaBytes)); } catch { return null; } } +function objectSchema(value: unknown): JsonSchema | null { + if (!isRecord(value) || value.type !== "object") return null; + if (!hasOnlyKeys(value, ROOT_SCHEMA_KEYS)) return null; + if (value.properties !== undefined && !isRecord(value.properties)) return null; + if ( + value.required !== undefined && + (!Array.isArray(value.required) || !value.required.every((key) => typeof key === "string")) + ) { + return null; + } + if (value.additionalProperties !== undefined && typeof value.additionalProperties !== "boolean") { + return null; + } + return value as JsonSchema; +} + function schemaProperties(schema: JsonSchema): Record { return isRecord(schema.properties) ? schema.properties : {}; } @@ -125,6 +145,17 @@ function propertySupports(value: unknown, expected: "string" | "boolean" | "stri return hasOnlyKeys(value, SCALAR_PROPERTY_KEYS) && value.type === expected; } +function propertyAcceptsString(value: unknown, actual: string): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, TODO_SCALAR_PROPERTY_KEYS)) return false; + if (value.type !== "string") return false; + if (value.enum === undefined) return true; + return ( + Array.isArray(value.enum) && + value.enum.every((entry) => typeof entry === "string") && + value.enum.includes(actual) + ); +} + function namedTools(tools: McpToolDefinition[], names: string[]): McpToolDefinition[] { const out: McpToolDefinition[] = []; for (const name of names) { @@ -228,6 +259,143 @@ function readBridge( return null; } +/** + * Recover priorities from the latest structured external TodoWrite call in + * OpenAI history. Cursor's native TodoItem wire schema has no priority field, + * so the bridge may preserve a prior declared value but must never invent one. + */ +export function extractLatestTodoHistory( + messages: ChatMessage[] +): CursorTodoHistoryItem[] | undefined { + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = messages[messageIndex]; + if (message.role !== "assistant") continue; + const calls = message.tool_calls; + if (!Array.isArray(calls)) continue; + for (let callIndex = calls.length - 1; callIndex >= 0; callIndex -= 1) { + const call = calls[callIndex]; + if ( + !isRecord(call) || + !isRecord(call.function) || + typeof call.function.name !== "string" || + !call.function.name || + typeof call.function.arguments !== "string" + ) { + return undefined; + } + if (!TODO_WRITE_TOOL_NAMES.includes(call.function.name.toLowerCase())) continue; + let parsed: unknown; + try { + parsed = JSON.parse(call.function.arguments); + } catch { + return undefined; + } + if (!isRecord(parsed) || !Array.isArray(parsed.todos)) return undefined; + const out: CursorTodoHistoryItem[] = []; + const contents = new Set(); + for (const value of parsed.todos) { + if (!isRecord(value) || typeof value.content !== "string" || !value.content) { + return undefined; + } + if (contents.has(value.content)) return undefined; + contents.add(value.content); + if ( + value.priority !== undefined && + (typeof value.priority !== "string" || !value.priority) + ) { + return undefined; + } + out.push({ + content: value.content, + ...(typeof value.priority === "string" ? { priority: value.priority } : {}), + }); + } + return out; + } + } + return undefined; +} + +/** + * Surface Cursor's native TodoWrite as a declared OpenAI TodoWrite call. + * OpenCode's todowrite replaces the complete list, so native merge=true is + * accepted only when the payload's content set exactly matches structured + * history and is therefore provably a complete replacement. + */ +export function bridgeCursorNativeTodoWrite( + event: CursorNativeTodoWrite, + tools: McpToolDefinition[], + history: CursorTodoHistoryItem[] | undefined +): CursorBuiltinToolBridge | null { + const nativeContents = new Set(); + for (const item of event.todos) { + if (nativeContents.has(item.content)) return null; + nativeContents.add(item.content); + } + + const historyByContent = new Map(); + for (const item of history ?? []) { + if (historyByContent.has(item.content)) return null; + historyByContent.set(item.content, item); + } + if ( + event.merge && + (!history || + historyByContent.size !== nativeContents.size || + [...nativeContents].some((content) => !historyByContent.has(content))) + ) { + return null; + } + + for (const tool of namedTools(tools, TODO_WRITE_TOOL_NAMES)) { + const schema = schemaFor(tool); + if (!schema) continue; + const properties = schemaProperties(schema); + const todosProperty = properties.todos; + if ( + !isRecord(todosProperty) || + !hasOnlyKeys(todosProperty, ARRAY_PROPERTY_KEYS) || + todosProperty.type !== "array" || + !isRecord(todosProperty.items) + ) { + continue; + } + const itemSchema = objectSchema(todosProperty.items); + if (!itemSchema) continue; + const itemProperties = schemaProperties(itemSchema); + if (!isRecord(itemProperties.content) || !isRecord(itemProperties.status)) continue; + + const bridgedTodos: Array> = []; + let compatible = true; + for (const item of event.todos) { + if ( + !propertyAcceptsString(itemProperties.content, item.content) || + !propertyAcceptsString(itemProperties.status, item.status) + ) { + compatible = false; + break; + } + const bridged: Record = { + content: item.content, + status: item.status, + }; + const priority = historyByContent.get(item.content)?.priority; + if (priority && propertyAcceptsString(itemProperties.priority, priority)) { + bridged.priority = priority; + } + if (!hasAllRequired(itemSchema, bridged)) { + compatible = false; + break; + } + bridgedTodos.push(bridged); + } + if (!compatible) continue; + const args: Record = { todos: bridgedTodos }; + if (hasAllRequired(schema, args)) return { toolName: tool.name, arguments: args }; + } + return null; +} + /** * Convert a Cursor-native built-in request into a declared external tool call. * Only event variants whose complete arguments are decoded are supported. diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 7cda7f4d914c..106c29ecba89 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -18,6 +18,7 @@ import zlib from "node:zlib"; import crypto from "node:crypto"; +import { decodeNativeTodoWriteCompletion } from "./cursorAgentProtobuf/nativeTodoWrite.ts"; import { WT_VARINT, WT_LEN, @@ -35,7 +36,6 @@ import { findField, decodeStringField, decodeVarintField, - type Field, } from "./cursorAgentProtobuf/wire.ts"; // ─── Field numbers (from agent.proto descriptor) ─────────────────────────── @@ -598,6 +598,15 @@ export type DecodedDelta = | { kind: "heartbeat" } | { kind: "tool_call_started" } | { kind: "tool_call_completed" } + | { + kind: "native_todo_write"; + toolCallId: string; + merge: boolean; + todos: Array<{ + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + }>; + } | { kind: "kv_server_message" } | { kind: "unknown"; field: number }; @@ -629,6 +638,10 @@ export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { out.push({ kind: "tool_call_started" }); break; case IU_TOOL_CALL_COMPLETED: + if (update.wireType === 2) { + const todoWrite = decodeNativeTodoWriteCompletion(update.bytes); + if (todoWrite) out.push(todoWrite); + } out.push({ kind: "tool_call_completed" }); break; case IU_TOKEN_DELTA: diff --git a/open-sse/utils/cursorAgentProtobuf/nativeTodoWrite.ts b/open-sse/utils/cursorAgentProtobuf/nativeTodoWrite.ts new file mode 100644 index 000000000000..96b4178342bf --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/nativeTodoWrite.ts @@ -0,0 +1,110 @@ +// Pure decoder for Cursor's native TodoWrite completion envelope. Kept separate +// from the main Agent codec so the frozen host remains below its file-size +// ceiling and TodoWrite's fail-closed wire semantics stay independently auditable. + +import { WT_LEN, WT_VARINT, decodeFields, type Field } from "./wire.ts"; + +export type NativeTodoWriteCompletion = { + kind: "native_todo_write"; + toolCallId: string; + merge: boolean; + todos: Array<{ + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + }>; +}; + +// ToolCallCompletedUpdate { tool_call_id (1), tool_call (2) } +// ToolCall.todo_write (9) -> TodoWriteDetails.args (1) +// TodoWriteArgs { todos (1 repeated), merge (2) } +// TodoItem { content (2), status (3) } +const TCCU_TOOL_CALL_ID = 1; +const TCCU_TOOL_CALL = 2; +const TOOL_CALL_TODO_WRITE = 9; +const TODO_WRITE_ARGS = 1; +const TODO_ARGS_TODOS = 1; +const TODO_ARGS_MERGE = 2; +const TODO_ITEM_CONTENT = 2; +const TODO_ITEM_STATUS = 3; + +function decodeNativeTodoStatus( + value: bigint +): "pending" | "in_progress" | "completed" | "cancelled" | null { + switch (value) { + case 1n: + return "pending"; + case 2n: + return "in_progress"; + case 3n: + return "completed"; + case 4n: + return "cancelled"; + default: + return null; + } +} + +function uniqueLenField( + fields: Field[], + fieldNumber: number +): Extract | null { + const matches = fields.filter((field) => field.fieldNumber === fieldNumber); + if (matches.length !== 1) return null; + const field = matches[0]; + return field.wireType === WT_LEN ? field : null; +} + +function uniqueVarintField( + fields: Field[], + fieldNumber: number +): Extract | null { + const matches = fields.filter((field) => field.fieldNumber === fieldNumber); + if (matches.length !== 1) return null; + const field = matches[0]; + return field.wireType === WT_VARINT ? field : null; +} + +function decodeNonEmptyUtf8(bytes: Buffer): string | null { + const value = bytes.toString("utf8"); + if (!value || !Buffer.from(value, "utf8").equals(bytes)) return null; + return value; +} + +export function decodeNativeTodoWriteCompletion(payload: Buffer): NativeTodoWriteCompletion | null { + const completedFields = decodeFields(payload); + const toolCallIdField = uniqueLenField(completedFields, TCCU_TOOL_CALL_ID); + const toolCallField = uniqueLenField(completedFields, TCCU_TOOL_CALL); + if (!toolCallIdField || !toolCallField) return null; + const toolCallId = decodeNonEmptyUtf8(toolCallIdField.bytes); + if (!toolCallId) return null; + + const todoWriteField = uniqueLenField(decodeFields(toolCallField.bytes), TOOL_CALL_TODO_WRITE); + if (!todoWriteField) return null; + const argsField = uniqueLenField(decodeFields(todoWriteField.bytes), TODO_WRITE_ARGS); + if (!argsField) return null; + + const argsFields = decodeFields(argsField.bytes); + const todos: NativeTodoWriteCompletion["todos"] = []; + const mergeFields = argsFields.filter((field) => field.fieldNumber === TODO_ARGS_MERGE); + if (mergeFields.length > 1) return null; + const mergeField = + mergeFields.length === 1 ? uniqueVarintField(argsFields, TODO_ARGS_MERGE) : null; + if (mergeFields.length === 1 && !mergeField) return null; + const merge = mergeField ? mergeField.varint !== 0n : false; + + for (const field of argsFields) { + if (field.fieldNumber === TODO_ARGS_MERGE) continue; + if (field.fieldNumber !== TODO_ARGS_TODOS) continue; + if (field.wireType !== WT_LEN) return null; + const itemFields = decodeFields(field.bytes); + const contentField = uniqueLenField(itemFields, TODO_ITEM_CONTENT); + const statusField = uniqueVarintField(itemFields, TODO_ITEM_STATUS); + if (!contentField || !statusField) return null; + const content = decodeNonEmptyUtf8(contentField.bytes); + const status = decodeNativeTodoStatus(statusField.varint); + if (!content || !status) return null; + todos.push({ content, status }); + } + + return { kind: "native_todo_write", toolCallId, merge, todos }; +} diff --git a/tests/unit/cursor-agent-protobuf.test.ts b/tests/unit/cursor-agent-protobuf.test.ts index 4ed3cc4d72fc..2ed49ac66d57 100644 --- a/tests/unit/cursor-agent-protobuf.test.ts +++ b/tests/unit/cursor-agent-protobuf.test.ts @@ -122,6 +122,146 @@ test("decode bounds hardening does not affect well-formed frames", () => { assert.deepEqual(decodeAgentServerMessage(asm), [{ kind: "text", text: "ok" }]); }); +test("decodeAgentServerMessage preserves a native TodoWrite completion", () => { + function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); + } + function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); + } + function lp(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); + } + function s(field: number, value: string): Buffer { + return lp(field, Buffer.from(value, "utf8")); + } + function vi(field: number, value: number): Buffer { + return Buffer.concat([tag(field, 0), v(value)]); + } + function todo(content: string, status: number): Buffer { + // TodoItem { content (2), status (3) } + return lp(1, Buffer.concat([s(2, content), vi(3, status)])); + } + + // ToolCallCompletedUpdate { + // tool_call_id (1), + // tool_call (2): ToolCall { + // todo_write (9): TodoWriteDetails { + // args (1): TodoWriteArgs { todos (1 repeated), merge (2) } + // } + // } + // } + const todoArgs = Buffer.concat([todo("Inspect files", 3), todo("Write report", 2), vi(2, 0)]); + const todoDetails = lp(1, todoArgs); + const toolCall = lp(9, todoDetails); + const completed = Buffer.concat([s(1, "toolu_todo_1"), lp(2, toolCall)]); + const interactionUpdate = lp(3, completed); + const asm = lp(1, interactionUpdate); + + assert.deepEqual(decodeAgentServerMessage(asm), [ + { + kind: "native_todo_write", + toolCallId: "toolu_todo_1", + merge: false, + todos: [ + { content: "Inspect files", status: "completed" }, + { content: "Write report", status: "in_progress" }, + ], + }, + { kind: "tool_call_completed" }, + ]); +}); + +test("native TodoWrite decoding rejects duplicate singular fields and malformed IDs", () => { + function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); + } + function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); + } + function lp(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); + } + function s(field: number, value: string): Buffer { + return lp(field, Buffer.from(value, "utf8")); + } + function vi(field: number, value: number): Buffer { + return Buffer.concat([tag(field, 0), v(value)]); + } + function todoItem(parts: Buffer[]): Buffer { + return lp(1, Buffer.concat(parts)); + } + function completed(idFields: Buffer[], todoWriteDetails: Buffer): Buffer { + const toolCall = lp(9, todoWriteDetails); + return lp(1, lp(3, Buffer.concat([...idFields, lp(2, toolCall)]))); + } + const genericOnly = [{ kind: "tool_call_completed" }]; + + const completeArgs = Buffer.concat([todoItem([s(2, "Keep me"), vi(3, 3)]), vi(2, 1)]); + const duplicateArgsExploit = completed( + [s(1, "todo-1")], + Buffer.concat([lp(1, Buffer.alloc(0)), lp(1, completeArgs)]) + ); + assert.deepEqual(decodeAgentServerMessage(duplicateArgsExploit), genericOnly); + + const duplicateStatus = completed( + [s(1, "todo-2")], + lp(1, todoItem([s(2, "Keep me"), vi(3, 1), vi(3, 99)])) + ); + assert.deepEqual(decodeAgentServerMessage(duplicateStatus), genericOnly); + + const duplicateMerge = completed( + [s(1, "todo-3")], + lp(1, Buffer.concat([todoItem([s(2, "Keep me"), vi(3, 3)]), vi(2, 0), vi(2, 1)])) + ); + assert.deepEqual(decodeAgentServerMessage(duplicateMerge), genericOnly); + + const duplicateContent = completed( + [s(1, "todo-4")], + lp(1, todoItem([s(2, "First"), s(2, "Second"), vi(3, 3)])) + ); + assert.deepEqual(decodeAgentServerMessage(duplicateContent), genericOnly); + + const emptyId = completed([s(1, "")], lp(1, completeArgs)); + assert.deepEqual(decodeAgentServerMessage(emptyId), genericOnly); + + const duplicateId = completed([s(1, "todo-5"), s(1, "todo-6")], lp(1, completeArgs)); + assert.deepEqual(decodeAgentServerMessage(duplicateId), genericOnly); + + const invalidUtf8Id = completed([lp(1, Buffer.from([0xff]))], lp(1, completeArgs)); + assert.deepEqual(decodeAgentServerMessage(invalidUtf8Id), genericOnly); + + const unknownStatus = completed([s(1, "todo-7")], lp(1, todoItem([s(2, "Keep me"), vi(3, 99)]))); + assert.deepEqual(decodeAgentServerMessage(unknownStatus), genericOnly); + + const missingStatus = completed([s(1, "todo-8")], lp(1, todoItem([s(2, "Keep me")]))); + assert.deepEqual(decodeAgentServerMessage(missingStatus), genericOnly); + + const wrongWireStatus = completed( + [s(1, "todo-9")], + lp(1, todoItem([s(2, "Keep me"), s(3, "completed")])) + ); + assert.deepEqual(decodeAgentServerMessage(wrongWireStatus), genericOnly); + + const invalidUtf8Content = completed( + [s(1, "todo-10")], + lp(1, todoItem([lp(2, Buffer.from([0xff])), vi(3, 3)])) + ); + assert.deepEqual(decodeAgentServerMessage(invalidUtf8Content), genericOnly); +}); + test("encodeAgentRunRequest embeds user text and resolves the model id", () => { const buf = encodeAgentRunRequest({ modelId: "auto", @@ -175,7 +315,10 @@ test("encodeAgentRunRequest keeps RequestedModel + parameters alongside ModelDet const composerText = composer.toString("latin1"); assert.ok(composerText.includes("composer-2"), "split model id still present"); assert.ok(composerText.includes("fast"), "'-fast' parameter id still present (RequestedModel)"); - assert.ok(composerText.includes("true"), "'-fast' parameter value still present (RequestedModel)"); + assert.ok( + composerText.includes("true"), + "'-fast' parameter value still present (RequestedModel)" + ); // auto → default, now appearing in both RequestedModel and ModelDetails. const auto = encodeAgentRunRequest({ modelId: "auto", userText: "hi" }); diff --git a/tests/unit/cursor-agent-tool-calls.test.ts b/tests/unit/cursor-agent-tool-calls.test.ts index abfbcff163d2..023e32c80c93 100644 --- a/tests/unit/cursor-agent-tool-calls.test.ts +++ b/tests/unit/cursor-agent-tool-calls.test.ts @@ -84,6 +84,23 @@ function buildShellStreamEvent( return lenPrefixed(2, esm); } +function buildNativeTodoCompletedEvent( + toolCallId: string, + todos: Array<{ content: string; status: number }>, + merge = false +): Buffer { + const todoArgs = Buffer.concat([ + ...todos.map((todo) => + lenPrefixed(1, Buffer.concat([stringField(2, todo.content), varintField(3, todo.status)])) + ), + varintField(2, merge ? 1 : 0), + ]); + const todoDetails = lenPrefixed(1, todoArgs); + const toolCall = lenPrefixed(9, todoDetails); + const completed = Buffer.concat([stringField(1, toolCallId), lenPrefixed(2, toolCall)]); + return lenPrefixed(1, lenPrefixed(3, completed)); +} + // ─── decodeProtobufValue round-trip tests ────────────────────────────────── test("decodeProtobufValue round-trips primitives", () => { @@ -312,6 +329,71 @@ test("processFrame bridges Cursor shell_stream to an external tool call and cold assert.ok(writes[0].includes(Buffer.from("Tool not available in this environment", "utf8"))); }); +test("processFrame bridges a complete native TodoWrite merge once and forces a cold resume", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("claude-fable-5-thinking-xhigh", (s) => emitted.push(s)); + const mcpTools = openAIToolsToMcpDefs([ + { + type: "function", + function: { + name: "todowrite", + parameters: { + type: "object", + properties: { + todos: { + type: "array", + items: { + type: "object", + properties: { + content: { type: "string" }, + status: { type: "string" }, + priority: { type: "string" }, + }, + required: ["content", "status", "priority"], + additionalProperties: false, + }, + }, + }, + required: ["todos"], + additionalProperties: false, + }, + }, + }, + ]); + const payload = buildNativeTodoCompletedEvent( + "toolu_todo_process", + [ + { content: "Inspect files", status: 3 }, + { content: "Write report", status: 2 }, + ], + true + ); + const acked = new Set(); + + const opts = { + mcpTools, + todoHistory: [ + { content: "Inspect files", priority: "high" }, + { content: "Write report", priority: "medium" }, + ], + }; + processFrame(payload, ctx, acked, opts); + processFrame(payload, ctx, acked, opts); + + assert.equal(ctx.endReason, "tool_calls"); + assert.equal(ctx.requiresColdResume, true); + assert.equal(ctx.pendingToolCalls.size, 0); + assert.equal(ctx.toolCalls.length, 1, "duplicate native completion frames must be deduplicated"); + assert.equal(ctx.toolCalls[0].name, "todowrite"); + assert.deepEqual(JSON.parse(ctx.toolCalls[0].argumentsJson), { + todos: [ + { content: "Inspect files", status: "completed", priority: "high" }, + { content: "Write report", status: "in_progress", priority: "medium" }, + ], + }); + assert.equal(emitted.length, 3, "role + tool init + tool args chunks"); +}); + test("infers the client platform from explicit environment metadata, not command text", () => { assert.equal( inferCursorClientPlatform([{ role: "system", content: "Client platform: win32" }]), diff --git a/tests/unit/cursor-builtin-tool-bridge.test.ts b/tests/unit/cursor-builtin-tool-bridge.test.ts index be8384ff8346..e7496432c2cd 100644 --- a/tests/unit/cursor-builtin-tool-bridge.test.ts +++ b/tests/unit/cursor-builtin-tool-bridge.test.ts @@ -2,6 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { bridgeCursorBuiltinTool, + bridgeCursorNativeTodoWrite, + extractLatestTodoHistory, selectCursorBridgeTools, } from "../../open-sse/executors/cursor/builtinToolBridge.ts"; import { @@ -52,6 +54,37 @@ const readTool: OpenAITool = { }, }; +const todoWriteTool: OpenAITool = { + type: "function", + function: { + name: "todowrite", + description: "Update the todo list", + parameters: { + type: "object", + properties: { + todos: { + type: "array", + items: { + type: "object", + properties: { + content: { type: "string" }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed", "cancelled"], + }, + priority: { type: "string", enum: ["high", "medium", "low"] }, + }, + required: ["content", "status", "priority"], + additionalProperties: false, + }, + }, + }, + required: ["todos"], + additionalProperties: false, + }, + }, +}; + function shellEvent(overrides: Partial = {}): ExecServerEvent { return { kind: "exec_shell_stream", @@ -105,6 +138,25 @@ test("restricts bridge candidates according to tool_choice", () => { selectCursorBridgeTools(tools, "auto")?.map((tool) => tool.name), ["pty_spawn", "read"] ); + assert.deepEqual( + selectCursorBridgeTools(tools, "required")?.map((tool) => tool.name), + ["pty_spawn", "read"] + ); + for (const malformed of [ + "bogus", + 1, + {}, + { type: "function" }, + { type: "function", function: {} }, + { type: "function", function: { name: 1 } }, + { type: "function", function: { name: "" } }, + ]) { + assert.equal( + selectCursorBridgeTools(tools, malformed as never), + undefined, + `malformed tool_choice must fail closed: ${JSON.stringify(malformed)}` + ); + } }); test("bridges Windows Cursor shell requests using the explicit client platform", () => { @@ -202,6 +254,192 @@ test("bridges exec_read to a schema-compatible read tool", () => { }); }); +test("bridges native TodoWrite and preserves priorities from structured history", () => { + const history = extractLatestTodoHistory([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_previous", + type: "function", + function: { + name: "todowrite", + arguments: JSON.stringify({ + todos: [ + { content: "Inspect files", status: "in_progress", priority: "high" }, + { content: "Write report", status: "pending", priority: "medium" }, + ], + }), + }, + }, + ], + }, + ]); + const result = bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_1", + merge: false, + todos: [ + { content: "Inspect files", status: "completed" }, + { content: "Write report", status: "in_progress" }, + ], + }, + defs([todoWriteTool]), + history + ); + assert.deepEqual(result, { + toolName: "todowrite", + arguments: { + todos: [ + { content: "Inspect files", status: "completed", priority: "high" }, + { content: "Write report", status: "in_progress", priority: "medium" }, + ], + }, + }); +}); + +test("TodoWrite priority history ignores tool_calls attached to non-assistant messages", () => { + const history = extractLatestTodoHistory([ + { + role: "user", + content: "Untrusted message", + tool_calls: [ + { + id: "call_user_supplied", + type: "function", + function: { + name: "todowrite", + arguments: JSON.stringify({ + todos: [{ content: "Injected", status: "pending", priority: "high" }], + }), + }, + }, + ], + }, + ]); + assert.equal(history, undefined); +}); + +test("TodoWrite priority history fails closed for malformed assistant tool_calls", () => { + const malformedCalls = [ + null, + {}, + { function: null }, + { function: {} }, + { function: { name: 1, arguments: "{}" } }, + { function: { name: "todowrite" } }, + { function: { name: "todowrite", arguments: 1 } }, + ]; + for (const call of malformedCalls) { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: [call], + }, + ] as unknown as Parameters[0]; + assert.doesNotThrow(() => extractLatestTodoHistory(messages)); + assert.equal(extractLatestTodoHistory(messages), undefined); + } +}); + +test("native TodoWrite bridge fails closed without required priority history", () => { + const result = bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_2", + merge: false, + todos: [{ content: "New item", status: "pending" }], + }, + defs([todoWriteTool]), + undefined + ); + assert.equal(result, null); +}); + +test("native TodoWrite bridge accepts only provably complete merge payloads", () => { + const history = [ + { content: "Same", priority: "high" }, + { content: "Other", priority: "medium" }, + ]; + assert.deepEqual( + bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_complete_merge", + merge: true, + todos: [ + { content: "Same", status: "completed" }, + { content: "Other", status: "completed" }, + ], + }, + defs([todoWriteTool]), + history + ), + { + toolName: "todowrite", + arguments: { + todos: [ + { content: "Same", status: "completed", priority: "high" }, + { content: "Other", status: "completed", priority: "medium" }, + ], + }, + } + ); + assert.equal( + bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_partial_merge", + merge: true, + todos: [{ content: "Same", status: "completed" }], + }, + defs([todoWriteTool]), + history + ), + null + ); + assert.equal( + bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_different_merge", + merge: true, + todos: [ + { content: "Same", status: "completed" }, + { content: "Different", status: "completed" }, + ], + }, + defs([todoWriteTool]), + history + ), + null, + "an equal-sized but different content set is not a complete replacement proof" + ); +}); + +test("native TodoWrite bridge rejects duplicate content", () => { + const history = [{ content: "Same", priority: "high" }]; + assert.equal( + bridgeCursorNativeTodoWrite( + { + kind: "native_todo_write", + toolCallId: "toolu_todo_duplicate", + merge: false, + todos: [ + { content: "Same", status: "completed" }, + { content: "Same", status: "pending" }, + ], + }, + defs([todoWriteTool]), + history + ), + null + ); +}); + test("rejects type-incompatible and constrained schemas", () => { const wrongCommand = bashTool({ type: "object",