From 2e0b0fec02c13d89d00fb03fbcc0f6fbb4fc7134 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 12:04:04 -0500 Subject: [PATCH] Bound Cursor todo activity before committing reducer state Preserve every todo identity and status with explicit detail omission when the serialized snapshot exceeds the rich-event limit. Retain full native detail for future deltas. Co-Authored-By: Paperclip --- .../drivers/acpx/cursor-extensions.test.ts | 52 ++++++++++++++++++ .../src/drivers/acpx/cursor-extensions.ts | 55 +++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts index b5b3560ae5..1e77f46018 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts @@ -68,6 +68,58 @@ describe("Cursor rich notifications", () => { const final = await normalize("cursor/update_todos", { toolCallId: "c", merge: false, todos: [{ id: "a", content: "Build", status: "completed" }] }); expect(final[0]?.payload).toMatchObject({ revision: 3, complete: true, steps: [{ body: "Build", status: "completed" }] }); }); + it("bounds a valid detailed snapshot before committing, retaining all identities and statuses", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "large" }); + const todos = Array.from({ length: 65 }, (_, i) => ({ id: `todo-${i}`, content: `${i}: ` + "x".repeat(3_970), status: i === 64 ? "cancelled" : "pending" })); + const request = { toolCallId: "large", merge: false, todos }; + expect(Buffer.byteLength(JSON.stringify(request))).toBeLessThan(256 * 1024); + const events = await normalize("cursor/update_todos", request); + for (const event of events) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + expect(events).toHaveLength(2); + expect(events[1]?.payload.category).toBe("cursor_todo_detail_partial"); + const steps = events[0]!.payload.steps as { stepId: string; body: string; status: string }[]; + const unbounded = structuredClone(events[0]!); + unbounded.payload.steps = steps.map((step, i) => ({ ...step, body: todos[i]!.content + (i === 64 ? "\n(Cancelled)" : "") })); + expect(Buffer.byteLength(JSON.stringify(unbounded))).toBeGreaterThan(240 * 1024); + expect(() => validateAcpxRichEvent(unbounded)).toThrow("Unsupported ACP rich activity event"); + expect(steps).toHaveLength(65); + expect(new Set(steps.map(step => step.stepId)).size).toBe(65); + steps.forEach((step, i) => { + expect(step.body.startsWith(`${i}: `)).toBe(true); + expect(step.body).toContain("[Further todo detail omitted]"); + expect(step.status).toBe(i === 64 ? "blocked" : "pending"); + }); + expect(steps[64]!.body).toMatch(/\(Cancelled\)$/); + const next = await normalize("cursor/update_todos", { toolCallId: "progress", merge: true, todos: [{ ...todos[0], status: "completed" }] }); + for (const event of next) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + expect(next[0]!.payload).toMatchObject({ revision: 2, steps: steps.map((step, i) => ({ stepId: step.stepId, status: i === 0 ? "completed" : step.status })) }); + const small = await normalize("cursor/update_todos", { toolCallId: "replace", merge: false, todos: [todos[0]] }); + expect(small).toHaveLength(1); + expect(small[0]!.payload).toMatchObject({ revision: 3, explanation: null, steps: [{ body: todos[0]!.content }] }); + }); + + it("bounds merged Unicode and escaped detail without poisoning later updates or rejected over-capacity merges", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "merged" }); + const content = "😀\u0001\\\"".repeat(790); + let events; + for (let batch = 0; batch < 16; batch++) { + const request = { toolCallId: `batch-${batch}`, merge: true, todos: Array.from({ length: 16 }, (_, i) => ({ id: `${batch * 16 + i}`, content, status: "pending" })) }; + expect(Buffer.byteLength(JSON.stringify(request))).toBeLessThan(256 * 1024); + events = await normalize("cursor/update_todos", request); + for (const event of events) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + } + const steps = events![0]!.payload.steps as { stepId: string; body: string }[]; + expect(steps).toHaveLength(256); + expect(steps.every(step => step.body.isWellFormed() && step.body.includes("[Further todo detail omitted]"))).toBe(true); + await expect(normalize("cursor/update_todos", { toolCallId: "overflow", merge: true, todos: [{ id: "extra", content: "Rejected", status: "pending" }] })).rejects.toThrow("256"); + // Shrink all but one item through deltas: full retained detail must return, + // and the rejected merge must neither increment revision nor add an item. + const restored = await normalize("cursor/update_todos", { toolCallId: "shrink", merge: true, todos: Array.from({ length: 255 }, (_, i) => ({ id: `${i + 1}`, content: "Done", status: "completed" })) }); + expect(restored).toHaveLength(1); + expect(() => validateAcpxRichEvent(restored[0]!)).not.toThrow(); + expect(restored[0]!.payload).toMatchObject({ revision: 17, steps: [{ stepId: steps[0]!.stepId, body: content }, ...Array.from({ length: 255 }, () => ({ body: "Done", status: "completed" }))] }); + }); + it("preserves delegation role/model/task and native duration", async () => { const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "turn" }); const [event] = await normalize("cursor/task", { toolCallId: "call", description: "Find references", prompt: "Search src", subagentType: { custom: "research" }, model: "model-1", agentId: "child-1", durationMs: 312 }); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts index 8516cb28b6..53383b344f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts @@ -247,6 +247,44 @@ export function normalizeCursorPlanRequest(value: unknown): { }; } +const RICH_EVENT_BYTE_LIMIT = 240 * 1024; +const TODO_DETAIL_OMITTED = "\n[Further todo detail omitted]"; + +/** Todo activity is a display snapshot, never the document sent for approval. */ +function boundTodoProjection(event: CanonicalProviderEvent, todos: Todo[]): CanonicalProviderEvent[] { + const fits = () => Buffer.byteLength(JSON.stringify(event)) <= RICH_EVENT_BYTE_LIMIT; + if (fits()) return [event]; + const summary = "Todo detail shortened to fit the activity limit; all todo identities and statuses are preserved."; + event.payload.explanation = summary; + const points = todos.map(todo => Array.from(todo.content)); + const prefixes = points.map(parts => { + let units = 0; + return parts.filter(point => { units += point.length; return units <= 3_940; }); + }); + const project = (limit: number) => { + event.payload.steps = todos.map((todo, index) => ({ + stepId: stableId(todo.id), + body: (points[index]!.length <= limit ? todo.content : prefixes[index]!.slice(0, limit).join("") + TODO_DETAIL_OMITTED) + + (todo.status === "cancelled" ? "\n(Cancelled)" : ""), + status: todo.status === "cancelled" ? "blocked" : todo.status, + })); + }; + // Measure serialized bytes (including JSON escaping), not string length. + // A common prefix bound retains useful detail for every todo and never splits + // a Unicode code point. At most 12 probes for the native 3,980-unit bound. + let low = 0; + let high = 3_980; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + project(mid); + if (fits()) low = mid; + else high = mid - 1; + } + project(low); + if (!fits()) throw new Error("Cursor todo identities exceed the activity byte bound"); + return [event, notice(`${event.itemId}:todo-detail`, "cursor_todo_detail_partial", summary)]; +} + /** Create one reducer per active turn; Cursor todo updates are deltas. */ export function createCursorNotificationNormalizer(input: { workspacePath: string; turnId: string }) { let todos = new Map(); @@ -261,16 +299,21 @@ export function createCursorNotificationNormalizer(input: { workspacePath: strin const next = request.merge ? new Map(todos) : new Map(); for (const todo of parseTodos(request.todos)) next.set(todo.id, todo); if (next.size > 256) throw new Error("Cursor todo snapshot exceeds 256 items"); - todos = next; - return [{ eventType: "plan.updated", itemId, payload: { - schema: "paperclip.plan.updated.v1", planId, revision: ++revision, + const event: CanonicalProviderEvent = { eventType: "plan.updated", itemId, payload: { + schema: "paperclip.plan.updated.v1", planId, revision: revision + 1, explanation: null, - steps: Array.from(todos.values(), todo => ({ stepId: stableId(todo.id), + steps: Array.from(next.values(), todo => ({ stepId: stableId(todo.id), body: todo.status === "cancelled" ? `${todo.content}\n(Cancelled)` : todo.content, status: todo.status === "cancelled" ? "blocked" : todo.status })), - complete: todos.size > 0 && Array.from(todos.values()).every(todo => todo.status === "completed"), + complete: next.size > 0 && Array.from(next.values()).every(todo => todo.status === "completed"), syncStatus: "not_applicable", documentRevision: null, - } }]; + } }; + const events = boundTodoProjection(event, [...next.values()]); + // Keep complete native detail for future deltas, but commit only after the + // entire display snapshot fits the rich-event boundary. + todos = next; + revision++; + return events; } if (method === "cursor/task") { const subtype = typeof request.subagentType === "string" ? request.subagentType : object(request.subagentType).custom;