From e4abc31f1e3f930e521a5bb62b38a9c5b28d8fb1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 05:20:54 -0700 Subject: [PATCH] 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 802b4f150dde74d58e9cb1587186eaa7f953cbf2..f68a3ee96e9b94b52a8bcf8daef9db6963e13f56 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -414,6 +414,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 bb874c38e87f616d74ce7e11ae2ee6cbc610dc8a..49f1b532a53adfca0b32198f18c172ef0237e594 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -327,6 +327,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. -- 2.54.0