diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e4fd848ab6e1..2c1d8895f20e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -20,6 +20,8 @@ const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; +const emitCursorUpdateTodos = process.env.T3_ACP_EMIT_CURSOR_UPDATE_TODOS === "1"; +const cursorTodosStatus = process.env.T3_ACP_CURSOR_TODOS_STATUS; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; @@ -1304,24 +1306,64 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } - yield* agent.client.sessionUpdate({ - sessionId: requestedSessionId, - update: { - sessionUpdate: "plan", - entries: [ + if (emitCursorUpdateTodos) { + const updateTodosInput = { + _toolName: "updateTodos", + todos: [ { + id: "1", content: "Inspect mock ACP state", - priority: "high", - status: "completed", + status: "TODO_STATUS_COMPLETED", }, { + id: "2", content: "Implement the requested change", - priority: "high", - status: "in_progress", + status: "TODO_STATUS_IN_PROGRESS", }, ], - }, - }); + }; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "update-todos-1", + title: "Update TODOs: Inspect mock ACP state", + kind: "other", + status: "in_progress", + rawInput: updateTodosInput, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "update-todos-1", + status: + cursorTodosStatus === "failed" || cursorTodosStatus === "in_progress" + ? cursorTodosStatus + : "completed", + }, + }); + } else { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + } yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index bdc818994a9a..bacce3b18f47 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -302,6 +302,70 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect.each(["completed", "failed", "in_progress"] as const)( + "emits a Cursor todo plan only after successful completion (%s)", + (status) => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-update-todos-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EMIT_CURSOR_UPDATE_TODOS: "1", + T3_ACP_CURSOR_TODOS_STATUS: status, + }), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEvents: Array = []; + const turnCompleted = yield* Deferred.make(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.orDie); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + yield* Deferred.await(turnCompleted); + + const planUpdates = runtimeEvents.filter((event) => event.type === "turn.plan.updated"); + assert.strictEqual(planUpdates.length, status === "completed" ? 1 : 0); + const planUpdate = planUpdates[0]; + if (planUpdate?.type === "turn.plan.updated") { + const completedIndex = runtimeEvents.findIndex( + (event) => event.type === "item.completed" && event.itemId === "update-todos-1", + ); + assert.isAtLeast(completedIndex, 0); + assert.isAbove(runtimeEvents.indexOf(planUpdate), completedIndex); + assert.deepStrictEqual(planUpdate.payload.plan, [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("sends selected project skills in Cursor's native slash form", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 925d585e5838..df81410a4130 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -75,6 +75,7 @@ import { extractAskQuestions, extractPlanMarkdown, extractTodosAsPlan, + extractTodosAsPlanFromToolCallInput, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; @@ -867,6 +868,18 @@ export function makeCursorAdapter( rawPayload: event.rawPayload, }), ); + const todosPlan = extractTodosAsPlanFromToolCallInput( + event.toolCall.data.rawInput, + ); + if (event.toolCall.status === "completed" && todosPlan) { + yield* emitPlanUpdate( + ctx, + todosPlan, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + } return; case "ContentDelta": ctx.assistantReply.push(event.text); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts index ba070c912952..9979ab32a86a 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.test.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -5,6 +5,7 @@ import { extractAskQuestions, extractPlanMarkdown, extractTodosAsPlan, + extractTodosAsPlanFromToolCallInput, } from "./CursorAcpExtension.ts"; describe("CursorAcpExtension", () => { @@ -107,6 +108,84 @@ describe("CursorAcpExtension", () => { }); }); + it("maps Cursor CLI TODO_STATUS_* values onto plan statuses", () => { + expect( + extractTodosAsPlan({ + toolCallId: "todos-cli-1", + todos: [ + { id: "1", content: "Inspect state", status: "TODO_STATUS_COMPLETED" }, + { id: "2", content: "Apply fix", status: "TODO_STATUS_IN_PROGRESS" }, + { id: "3", content: "Write tests", status: "TODO_STATUS_PENDING" }, + ], + merge: false, + }), + ).toEqual({ + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Apply fix", status: "inProgress" }, + { step: "Write tests", status: "pending" }, + ], + }); + }); + + it("projects Cursor CLI updateTodos tool-call input into a plan", () => { + const expected = { + plan: [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ], + }; + expect( + extractTodosAsPlanFromToolCallInput({ + _toolName: "updateTodos", + todos: [ + { + id: "1", + content: "Inspect mock ACP state", + status: "TODO_STATUS_COMPLETED", + createdAt: "2026-09-11T01:00:00.000Z", + updatedAt: "2026-09-11T01:00:00.000Z", + dependencies: [], + }, + { + id: "2", + content: "Implement the requested change", + status: "TODO_STATUS_IN_PROGRESS", + }, + ], + }), + ).toEqual(expected); + expect( + extractTodosAsPlanFromToolCallInput({ + _toolName: "TodoWrite", + todos: [ + { content: "Inspect mock ACP state", status: "TODO_STATUS_COMPLETED" }, + { content: "Implement the requested change", status: "TODO_STATUS_IN_PROGRESS" }, + ], + }), + ).toEqual(expected); + }); + + it("ignores tool-call input that is not Cursor updateTodos", () => { + expect( + extractTodosAsPlanFromToolCallInput({ + _toolName: "editFile", + todos: [{ content: "Should not become a plan", status: "TODO_STATUS_IN_PROGRESS" }], + }), + ).toBeUndefined(); + expect( + extractTodosAsPlanFromToolCallInput({ + todos: [{ content: "Missing tool name", status: "TODO_STATUS_PENDING" }], + }), + ).toBeUndefined(); + expect( + extractTodosAsPlanFromToolCallInput({ + _toolName: "TodoWrite", + todos: [], + }), + ).toBeUndefined(); + }); + it("falls back to the title when content is present but blank", () => { expect( extractTodosAsPlan({ diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index 05fc53f4a6b5..5893b1d35003 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -86,6 +86,35 @@ export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type) return params.plan || "# Plan\n\n(Cursor did not supply plan text.)"; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCursorUpdateTodosToolName(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const compact = value.replaceAll("_", "").toLowerCase(); + return compact === "updatetodos" || compact === "todowrite"; +} + +function normalizeCursorTodoStatus( + status: string | undefined, +): "pending" | "inProgress" | "completed" { + const compact = (status ?? "") + .trim() + .replace(/^TODO_STATUS_/i, "") + .replaceAll("_", "") + .toLowerCase(); + if (compact === "completed") { + return "completed"; + } + if (compact === "inprogress") { + return "inProgress"; + } + return "pending"; +} + export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type): { readonly explanation?: string; readonly plan: ReadonlyArray<{ @@ -101,13 +130,41 @@ export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type) if (step === "") { return []; } - const status: "pending" | "inProgress" | "completed" = - todo.status === "completed" - ? "completed" - : todo.status === "in_progress" || todo.status === "inProgress" - ? "inProgress" - : "pending"; - return [{ step, status }]; + return [{ step, status: normalizeCursorTodoStatus(todo.status) }]; }); return { plan }; } + +/** + * Cursor CLI ACP does not send `cursor/update_todos`. It emits a generic + * `updateTodos` / `TodoWrite` tool call whose `rawInput` carries the todo list. + */ +export function extractTodosAsPlanFromToolCallInput( + rawInput: unknown, +): ReturnType | undefined { + if (!isRecord(rawInput) || !isCursorUpdateTodosToolName(rawInput._toolName)) { + return undefined; + } + if (!Array.isArray(rawInput.todos)) { + return undefined; + } + const todos = rawInput.todos.flatMap((item) => { + if (!isRecord(item)) { + return []; + } + return [ + { + ...(typeof item.id === "string" ? { id: item.id } : {}), + ...(typeof item.content === "string" ? { content: item.content } : {}), + ...(typeof item.title === "string" ? { title: item.title } : {}), + ...(typeof item.status === "string" ? { status: item.status } : {}), + }, + ]; + }); + const extracted = extractTodosAsPlan({ + toolCallId: "updateTodos", + todos, + merge: false, + }); + return extracted.plan.length > 0 ? extracted : undefined; +}