From 9d5aafd2aadb7545e7d8f5979b5f0ee9f1df544e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 02:20:21 -0700 Subject: [PATCH 1/2] fix(server): drop superseded tool updates from snapshots A tool.updated row is the in-flight snapshot of a call; once the call completes, the tool.completed row carries the final state and both clients fold every matching update into it. Shipping the updates buys nothing: 47k such rows exist in one real database, and a single thread carries 3,291 of them. Filter them out of thread snapshots, mirroring the existing context-window dedup. Matching is per turn and only against a LATER completion, so a revert that discards the completing turn cannot leave a call unrepresented, and a later update under the same identity (the next call, still in flight) survives. Live events are untouched. Rows are matched on the same identity the clients collapse by: an explicit data.toolCallId when the adapter emits one, otherwise the itemType/title/detail triple. No tool lifecycle row in the real db carries a toolCallId, so the fallback does the work. Co-Authored-By: Claude Fable 5 --- .../ActivityPayloadProjection.ts | 94 ++++++++++- .../test/ActivityPayloadProjection.test.ts | 151 ++++++++++++++++++ 2 files changed, 242 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 67896961b38..9b10551b325 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -247,6 +247,94 @@ 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. + */ +function dropSupersededToolUpdatedActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const completionIndicesByKey = new Map(); + 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); + }); +} + export function projectThreadDetailSnapshot( snapshot: OrchestrationThreadDetailSnapshot, ): OrchestrationThreadDetailSnapshot { @@ -254,9 +342,9 @@ export function projectThreadDetailSnapshot( ...snapshot, thread: { ...snapshot.thread, - activities: dropStaleContextWindowActivities(snapshot.thread.activities).map( - projectActivityPayload, - ), + activities: dropSupersededToolUpdatedActivities( + dropStaleContextWindowActivities(snapshot.thread.activities), + ).map(projectActivityPayload), }, }; } diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index d6098937e7f..fd0be4a00e8 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -233,6 +233,157 @@ 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) { + 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("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; + + 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, From 20624c3527494644ac51b651da169ddeba6fd3a0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 05:20:54 -0700 Subject: [PATCH 2/2] docs(server): pin interleaved-collapse divergence as intentional with test Bugbot flagged that clients collapse only adjacent lifecycle rows, so dropping a superseded update separated by an interleaved parallel call diverges from full-history rendering. Measured on a real database: 1.5% of dropped rows (553/36,581), all pure in-flight state whose final result the retained completion still shows, and zero dropped rows carry a client-merged payload field their completion lacks (verified across all 49,515 update rows). Documents the tradeoff and adds a test pinning it. Co-Authored-By: Claude Fable 5 --- .../ActivityPayloadProjection.ts | 13 +++++++++++ .../test/ActivityPayloadProjection.test.ts | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 9b10551b325..9ac34f7713f 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -296,6 +296,19 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * 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, diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index fd0be4a00e8..660666511ac 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -303,6 +303,28 @@ describe("superseded tool.updated snapshot dedup", () => { 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.