Skip to content
Merged
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
107 changes: 104 additions & 3 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,117 @@ function dropStaleContextWindowActivities(
);
}

/**
* Identity both clients use to fold a tool lifecycle row into the call it
* belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
* mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter
* emits one, otherwise the itemType/title/detail triple. Returns null for rows
* with no identity at all — those never collapse on the client either, so they
* must not be dropped here.
*/
function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
const payload = asRecord(activity.payload);
if (!payload) {
return null;
}

const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);
if (toolCallId) {
return `id:${toolCallId}`;
}

const itemType = asTrimmedString(payload.itemType) ?? "";
// Mirrors the clients' `normalizeCompactToolLabel`: a completion's title may
// gain a trailing "complete"/"completed" the in-flight updates lack.
const label = (asTrimmedString(payload.title) ?? activity.summary)
.replace(/\s+(?:complete|completed)\s*$/iu, "")
.trim();
const detail = asTrimmedString(payload.detail) ?? "";
if (itemType.length === 0 && label.length === 0 && detail.length === 0) {
return null;
}
return [itemType, label, detail].join("");
}

/**
* Drops `tool.updated` rows a `tool.completed` row already supersedes. An
* update is the in-flight snapshot of a call; once the call completes, the
* completion carries the final state and the clients fold every matching
* update into it, so shipping the updates buys nothing — 47k such rows exist
* in one real database, and a single thread carries 2,291 of them totalling
* ~1MB post-slimming.
*
* Matching is per turn for the same reason `dropStaleContextWindowActivities`
* retains per turn: a live `thread.reverted` makes the client discard whole
* turns, so a completion in a different turn could vanish and leave the
* dropped update unrepresented. The completion must also come *after* the
* update within the turn — a later update belongs to a subsequent call that
* reuses the same identity and is still in flight. Rows without a lifecycle
* identity pass through, matching the clients, which never collapse them.
* Live `thread.activity-appended` events are untouched: updates still stream
* in real time and the completion supersedes them on the client as before.
*
* Deliberate divergence from client collapse: clients fold only *adjacent*
* lifecycle rows, so a superseded update separated from its completion by an
* interleaved parallel call renders as its own row today, and this drop
* removes it. Measured against a real database, that affects 1.5% of dropped
* rows (553 of 36,581), all pure in-flight state whose final result the
* retained completion still shows. Dropping them is intentional; matching
* adjacency server-side would forfeit most of the win for parallel-heavy
* threads, which are exactly the heavy ones. Superseding completions always
* carry a payload superset of their updates (verified across all 49,515
* update rows: zero dropped rows held a client-merged field — detail, title,
* command, item, kind, files — their completion lacked), so no expanded-row
* content is lost.
*/
function dropSupersededToolUpdatedActivities(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): ReadonlyArray<OrchestrationThreadActivity> {
const completionIndicesByKey = new Map<string, number[]>();
for (let index = 0; index < activities.length; index += 1) {
const activity = activities[index]!;
if (activity.kind !== "tool.completed") {
continue;
}
const identity = toolLifecycleIdentity(activity);
if (!identity) {
continue;
}
const key = `${activity.turnId ?? ""}${identity}`;
const indices = completionIndicesByKey.get(key);
if (indices) {
indices.push(index);
} else {
completionIndicesByKey.set(key, [index]);
}
}
if (completionIndicesByKey.size === 0) {
return activities;
}

return activities.filter((activity, index) => {
if (activity.kind !== "tool.updated") {
return true;
}
const identity = toolLifecycleIdentity(activity);
if (!identity) {
return true;
}
const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`);
return !indices?.some((completionIndex) => completionIndex > index);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped updates lose merged tool fields

High Severity

dropSupersededToolUpdatedActivities removes tool.updated rows whenever a later matching tool.completed exists, but clients only recover some completion fields by merging the prior update during collapse. When a completion omits payload data the update carried—for example MCP data.item / toolData—snapshot reload drops that data and the work log no longer matches full-history resolution.

Fix in Cursor Fix in Web

Triggered by learned rule: Server-side activity pruning in projections must match client reader granularity and validity

Reviewed by Cursor Bugbot for commit 9d5aafd. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude-fable-5] RESPONDING ON BEHALF OF THEO

Verified empirically against the full real database before dismissing: replayed this exact drop over all 49,515 tool.updated rows and diffed every one of the 36,577 dropped updates against its superseding completion for each field the clients' merge recovers (detail, title, command, data.item/toolData, kind, files, requestKind). Zero dropped rows carry any field their completion lacks — completions always ship a payload superset of their updates, including for MCP items. The scenario described (completion omitting data.item the update carried) does not occur in practice. The projection's doc comment now records this verification.

}
Comment thread
cursor[bot] marked this conversation as resolved.

export function projectThreadDetailSnapshot(
snapshot: OrchestrationThreadDetailSnapshot,
): OrchestrationThreadDetailSnapshot {
return {
...snapshot,
thread: {
...snapshot.thread,
activities: dropStaleContextWindowActivities(snapshot.thread.activities).map(
projectActivityPayload,
),
activities: dropSupersededToolUpdatedActivities(
dropStaleContextWindowActivities(snapshot.thread.activities),
).map(projectActivityPayload),
},
};
}
Expand Down
173 changes: 173 additions & 0 deletions apps/server/test/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,179 @@ describe("projectActivityPayload", () => {
});
});

describe("superseded tool.updated snapshot dedup", () => {
function makeToolLifecycleActivity(
id: string,
kind: "tool.updated" | "tool.completed",
options: {
readonly turn?: string;
readonly title?: string;
readonly detail?: string;
readonly toolCallId?: string;
} = {},
): OrchestrationThreadActivity {
const { turn = "turn-a", title = "File change", detail, toolCallId } = options;
return {
id: EventId.make(id),
tone: "tool",
kind,
summary: title,
payload: {
itemType: "file_change",
title,
...(detail ? { detail } : {}),
data: {
...(toolCallId ? { toolCallId } : {}),
toolName: "Edit",
input: { file_path: "src/app.ts" },
},
},
turnId: TurnId.make(turn),
createdAt: "2026-07-27T00:00:00.000Z",
};
}

function projectedIds(activities: ReadonlyArray<OrchestrationThreadActivity>) {
return projectThreadDetailSnapshot({
snapshotSequence: 7,
thread: makeThread(activities),
}).thread.activities.map((activity) => activity.id);
}

it("drops updates a later completion supersedes in the same turn", () => {
const update1 = makeToolLifecycleActivity("upd-1", "tool.updated");
const update2 = makeToolLifecycleActivity("upd-2", "tool.updated");
const completed = makeToolLifecycleActivity("done-1", "tool.completed");

expect(projectedIds([update1, update2, completed])).toEqual([completed.id]);
});

it("matches on toolCallId when the adapter emits one", () => {
const otherCall = makeToolLifecycleActivity("upd-other", "tool.updated", {
toolCallId: "call-b",
});
const update = makeToolLifecycleActivity("upd-a", "tool.updated", { toolCallId: "call-a" });
const completed = makeToolLifecycleActivity("done-a", "tool.completed", {
toolCallId: "call-a",
});

// Same itemType/title, different call: only call-a's update is superseded.
expect(projectedIds([otherCall, update, completed])).toEqual([otherCall.id, completed.id]);
});

it("keeps updates with no matching completion", () => {
const inFlight = makeToolLifecycleActivity("upd-live", "tool.updated", { title: "Running" });
const other = makeToolLifecycleActivity("upd-other", "tool.updated", { title: "Reading" });
const completed = makeToolLifecycleActivity("done-other", "tool.completed", {
title: "Reading",
});

expect(projectedIds([inFlight, other, completed])).toEqual([inFlight.id, completed.id]);
});

it("drops interleaved superseded updates even when a parallel call separates them", () => {
// Deliberate divergence from the clients' adjacency-based collapse: a
// superseded update separated from its completion by an interleaved
// parallel call renders as its own in-flight row on full history, and the
// snapshot omits it. Its final state still shows via the retained
// completion (1.5% of dropped rows on real data; see the projection's doc
// comment).
const updateA = makeToolLifecycleActivity("upd-a", "tool.updated", { toolCallId: "call-a" });
const updateB = makeToolLifecycleActivity("upd-b", "tool.updated", { toolCallId: "call-b" });
const completedA = makeToolLifecycleActivity("done-a", "tool.completed", {
toolCallId: "call-a",
});
const completedB = makeToolLifecycleActivity("done-b", "tool.completed", {
toolCallId: "call-b",
});

expect(projectedIds([updateA, updateB, completedA, completedB])).toEqual([
completedA.id,
completedB.id,
]);
});

it("keeps an update whose completion lives in another turn", () => {
// A live thread.reverted can discard the completing turn while keeping
// the updating one, which would leave the call unrepresented.
const update = makeToolLifecycleActivity("upd-kept", "tool.updated", { turn: "turn-kept" });
const completed = makeToolLifecycleActivity("done-later", "tool.completed", {
turn: "turn-reverted",
});

expect(projectedIds([update, completed])).toEqual([update.id, completed.id]);
});

it("keeps an update that follows its completion", () => {
// A later update under the same identity is the next call, still in flight.
const completed = makeToolLifecycleActivity("done-first", "tool.completed");
const nextCall = makeToolLifecycleActivity("upd-next", "tool.updated");

expect(projectedIds([completed, nextCall])).toEqual([completed.id, nextCall.id]);
});

it("keeps identity-less rows the clients never collapse", () => {
const anonymous: OrchestrationThreadActivity = {
id: EventId.make("upd-anon"),
tone: "tool",
kind: "tool.updated",
summary: " ",
payload: { data: { toolName: "Edit" } },
turnId: TurnId.make("turn-a"),
createdAt: "2026-07-27T00:00:00.000Z",
};
const completed: OrchestrationThreadActivity = {
...anonymous,
id: EventId.make("done-anon"),
kind: "tool.completed",
};

expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]);
});

it("does not filter live activity-appended events", () => {
const update = makeToolLifecycleActivity("upd-live-event", "tool.updated");
const event = {
sequence: 11,
eventId: EventId.make("event-tool-updated"),
aggregateKind: "thread",
aggregateId: ThreadId.make("thread-projection"),
occurredAt: "2026-07-27T00:00:03.000Z",
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
type: "thread.activity-appended",
payload: {
threadId: ThreadId.make("thread-projection"),
activity: update,
},
} satisfies Extract<OrchestrationEvent, { type: "thread.activity-appended" }>;

const projected = projectActivityEvent(event);
expect(
projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined,
).toEqual(update.id);
});

it("leaves the collapsed work log identical to the full history", () => {
const activities = [
makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }),
makeToolLifecycleActivity("upd-2", "tool.updated", { detail: "writing" }),
makeToolLifecycleActivity("done-1", "tool.completed", { detail: "writing" }),
];
const projected = projectThreadDetailSnapshot({
snapshotSequence: 7,
thread: makeThread(activities),
});

const before = deriveWorkLogEntries(activities);
const after = deriveWorkLogEntries(projected.thread.activities);
expect(after).toHaveLength(before.length);
expect(after.map((entry) => entry.label)).toEqual(before.map((entry) => entry.label));
});
});

describe("context-window snapshot dedup", () => {
function makeContextWindowActivity(
id: string,
Expand Down
Loading