From 56857e99006bd45f6e4d2d7d6c0dd239fc79f32f Mon Sep 17 00:00:00 2001 From: Diogo Soares Rodrigues Date: Sat, 25 Jul 2026 11:57:19 -0300 Subject: [PATCH] fix(cursor): refuse todo snapshots that collide on content Cursor's wire model identifies todos by `id` and can represent two rows sharing the same content. The local list is keyed by content alone (`findTaskByContent`) and `todo` rejects a duplicate outright, so importing such a snapshot would leave every later `done`/`drop`/`rm` resolving to the first row and the second permanently unaddressable. Preserving identity would mean threading an id through the tool, the renderer, and the persisted phases; the local model has no such field. Refusing is consistent with the two refusals already there (filtered and short reads): local state untouched, the call still settles as a no-op. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cursor.ts | 22 ++++++- packages/ai/test/cursor-todo-bridge.test.ts | 66 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b84128a39..fb0a2a72c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,6 +14,7 @@ - Fixed a server-resolved Cursor todo call leaving its transcript block stuck pending: the synthetic completion was emitted under a freshly generated id instead of the streamed call id the interactive transcript filed the block under, so the card animated indefinitely. The settled call id is now handed to the sync handler. - Fixed server-resolved Cursor todo blocks disappearing from rebuilt transcripts: nothing produced a `toolResult` for them, and `buildSessionContext` strips any `toolCall` left unpaired, so the interaction vanished on reload, branch switch, or transcript rebuild. The result the host builds is now persisted verbatim — it carries the `details.phases` the todo renderer rebuilds the list from, which a summary-only result would have replayed as `0 tasks`. - Fixed a refused or failed Cursor todo call leaving its card animating forever. Only a successful snapshot settled the block, so a `read_todos` narrowed by a filter and a server `UpdateTodosError` both went unanswered — no `tool_execution_end`, and no `toolResult` to keep the block from being stripped on rebuild. Every completed native todo call now settles. A server error is carried through as a failed result rather than collapsed into the benign "nothing to mirror" case, which would have replayed the failure as a success. +- Hardened Cursor todo mirroring against snapshots whose rows collide on content. Cursor's wire model identifies todos by `id` and can represent two rows sharing the same text; the local list is keyed by content alone and the `todo` tool rejects a duplicate outright, so importing such a pair would leave every later `done`/`drop`/`rm` resolving to the first row and the second permanently unaddressable. The snapshot is now refused like any other that cannot be represented locally — local state is left untouched and the call still settles as a no-op. ## [17.1.3] - 2026-07-24 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 2567f0778..0bc420b91 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -2120,8 +2120,12 @@ function mapTodoSnapshot(todos: CursorTodoItem[]): CursorTodoSnapshotItem[] { * reports the full size. Mirroring a partial response would delete every task * it omitted, so filtered and short reads are both refused here. * - * Returns `null` when no full snapshot is available, which the caller MUST - * treat as "leave local state untouched". + * A snapshot whose rows are not unique by content is refused for a different + * reason: Cursor keys todos by `id`, the local list is keyed by content, and + * the collision is unrepresentable rather than merely partial. + * + * Returns `null` when no usable full snapshot is available, which the caller + * MUST treat as "leave local state untouched". */ function extractTodoSnapshot(toolCall: CursorTodoToolCall): CursorTodoSnapshot | null { const { update, read } = selectTodoCalls(toolCall); @@ -2143,8 +2147,20 @@ function extractTodoSnapshot(toolCall: CursorTodoToolCall): CursorTodoSnapshot | if (read && typeof totalCount === "number" && totalCount !== todos.length) { return null; } + const mapped = mapTodoSnapshot(todos); + // The wire model identifies rows by `id` and can represent two rows sharing + // `content`; the local list is keyed by content alone (`findTaskByContent`) + // and `todo` rejects a duplicate outright. Importing such a snapshot would + // leave every later `done`/`drop`/`rm` resolving to the first row and the + // second permanently unaddressable, so it is refused like any other snapshot + // that cannot be represented locally. + const seen = new Set(); + for (const todo of mapped) { + if (seen.has(todo.content)) return null; + seen.add(todo.content); + } return { - todos: mapTodoSnapshot(todos), + todos: mapped, // Presentation-only: the snapshot is already the settled full list. merged: result.value?.wasMerge === true, }; diff --git a/packages/ai/test/cursor-todo-bridge.test.ts b/packages/ai/test/cursor-todo-bridge.test.ts index 5eac72637..fffd80569 100644 --- a/packages/ai/test/cursor-todo-bridge.test.ts +++ b/packages/ai/test/cursor-todo-bridge.test.ts @@ -485,6 +485,72 @@ describe("cursor native todo bridge (wire-encoded protobuf)", () => { expect(h.snapshots).toEqual([]); }); + it("refuses a snapshot whose rows collide on content", () => { + // The wire model identifies rows by `id` and can represent two rows with + // the same `content`, so the bridge must survive one. The local list is + // keyed by content alone, so importing the pair would make every later + // `done`/`drop` resolve to the first row and strand the second. + // + // Positive control: the same two rows with distinct content do sync, so + // the refusal below is attributable to the collision. + const distinct = drive( + updateCall( + items([ + ["1", "task a", 1], + ["2", "task b", 1], + ]), + 2, + ), + ); + expect(distinct.snapshots).toHaveLength(1); + + const h = drive( + updateCall( + items([ + ["1", "same task", 1], + ["2", "same task", 3], + ]), + 2, + ), + ); + const callId = todoBlocks(h)[0].id; + + expect(todoBlocks(h)).toHaveLength(1); + // Nothing mutable reaches the host: no snapshot at all, so it cannot + // mirror a list the local model is unable to key. + expect(h.snapshots).toEqual([]); + expect(h.syncCalls).toEqual([{ snapshot: null, toolCallId: callId, error: null }]); + // Still settles: the call happened, only the mirror was declined. Without + // a completion the card animates forever, and without a paired result the + // block is stripped on rebuild. It reads as a benign no-op, not a failure. + expect(h.toolResults.map(r => r.toolCallId)).toEqual([callId]); + expect(h.toolResults[0]).toMatchObject({ role: "toolResult", toolName: "todo", isError: false }); + }); + + it("settles a collided snapshot as a no-op when no host handler is registered", () => { + // Without a host the provider builds the result itself: a declined mirror + // must read as "nothing changed", never as an empty list, or a rebuilt + // transcript would claim the server wiped every task. + const h = newHarness(); + h.state.onTodoSnapshot = undefined; + const toolCall = updateCall( + items([ + ["1", "same task", 1], + ["2", "same task", 3], + ]), + 2, + ); + for (const kind of ["toolCallStarted", "toolCallCompleted"] as const) { + processInteractionUpdate(wireUpdate(kind, toolCall) as never, h.output, h.stream, h.state, h.usageState); + } + + expect(h.toolResults[0]).toMatchObject({ + toolCallId: todoBlocks(h)[0].id, + isError: false, + content: [{ type: "text", text: "No todo changes" }], + }); + }); + it("refuses a wire-decoded read_todos narrowed by status_filter", () => { const rows: [string, string, number][] = [["1", "only task", 3]]; // Positive control: the identical response without the filter does sync,