mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
76d943e625
commit
2e0b0fec02
2 files changed
+101
-6
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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<string, Todo>();
|
||||
@@ -261,16 +299,21 @@ export function createCursorNotificationNormalizer(input: { workspacePath: strin
|
||||
const next = request.merge ? new Map(todos) : new Map<string, Todo>();
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user