Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderRuntimeEvent> = [];
const turnCompleted = yield* Deferred.make<void>();
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;
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions apps/server/src/provider/acp/CursorAcpExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractAskQuestions,
extractPlanMarkdown,
extractTodosAsPlan,
extractTodosAsPlanFromToolCallInput,
} from "./CursorAcpExtension.ts";

describe("CursorAcpExtension", () => {
Expand Down Expand Up @@ -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({
Expand Down
71 changes: 64 additions & 7 deletions apps/server/src/provider/acp/CursorAcpExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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<{
Expand All @@ -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<typeof extractTodosAsPlan> | 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;
}
Loading