From 6221b0c8b76c1ae522431dacd6beba5b9c28536e Mon Sep 17 00:00:00 2001 From: Monarch Maisuriya Date: Wed, 4 Nov 2026 14:32:25 +0530 Subject: [PATCH] Fork from earlier replies when a plugin card was updated later (#8746) The fork checkpoint check refused a fork when any projected row spanned the selected reply. A plugin card re-appended with the same id keeps its first position and stretches to its latest update, so it spanned every reply in between, although the fork never copies plugin cards. The check now looks only at the row kinds the fork copies, taken from the same constant as the copy. Closes #8745 Co-authored-by: Claude Opus 5.5 --- docs/timeline-sync.md | 7 +++-- .../src/server/agent/activity-curator.test.ts | 28 +++++++++++++++++++ .../src/server/agent/activity-curator.ts | 17 +++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3d6af249bf..e12b4a495a 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. The daemon validates the epoch and locates the projected item at the selected position. A fork -includes projected items through that checkpoint. If an item spans the checkpoint and changed -afterward, the daemon refuses that fork with an actionable error: discarded historical payloads -cannot be reconstructed from sequence metadata. Forking the current context remains available. +includes projected items through that checkpoint. If an item the fork copies spans the checkpoint +and changed afterward, the daemon refuses that fork with an actionable error: discarded historical +payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card +updated after the checkpoint does not block it. Forking the current context remains available. ## Resume behavior diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 4e9ce49528..6a1d85bcf0 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -454,6 +454,34 @@ second line'`, ).toThrow("Fork from a later completed response"); }); + it("forks from a checkpoint when a plugin card was updated after it", () => { + const card = (seq: number, turns: number) => + row(seq, { + type: "plugin", + id: "memory-receipt", + pluginId: "memorise", + kind: "memory-receipt", + version: 1, + data: { turns }, + }); + const result = buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "First question", messageId: "user-1" }), + card(2, 1), + row(3, { type: "assistant_message", text: "First answer.", messageId: "assistant-1" }), + row(4, { type: "user_message", text: "Second question", messageId: "user-2" }), + row(5, { type: "assistant_message", text: "Second answer.", messageId: "assistant-2" }), + card(6, 2), + ], + }); + + expect(result.attachment.text).toContain("[User] First question"); + expect(result.attachment.text).toContain("[Assistant] First answer."); + expect(result.attachment.text).not.toContain("Second question"); + expect(result.attachment.text).not.toContain("memory-receipt"); + }); + it("selects a synthetic assistant error by its timeline cursor", () => { const result = buildAgentForkContextAttachment({ cursorBoundary: { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 2a7cbff5e6..a0c537254a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -13,6 +13,12 @@ const MAX_TOOL_SUMMARY_CHARS = 200; const MAX_FORK_CONTEXT_CHARS = 100_000; const MAX_FORK_METADATA_CHARS = 1_000; const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; +// A fork copies only these kinds, so only they can make a checkpoint stale. +const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [ + "user_message", + "assistant_message", + "tool_call", +]; interface ActivityCuratorOptions { maxItems?: number; @@ -269,7 +275,14 @@ function selectForkContextRows(input: { ); } const boundarySeq = projectedRows[boundaryIndex].seqEnd; - if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + if ( + projectedRows.some( + (row) => + FORK_CONTEXT_KINDS.includes(row.item.type) && + row.seqStart <= boundarySeq && + row.seqEnd > boundarySeq, + ) + ) { throw new Error( "This checkpoint changed after it was created. Fork from a later completed response instead.", ); @@ -349,7 +362,7 @@ export function buildAgentForkContextAttachment(input: { const entries = curateProjectedActivityEntries(selected.items, { maxItems: 0, labelAssistantMessages: true, - includeKinds: ["user_message", "assistant_message", "tool_call"], + includeKinds: FORK_CONTEXT_KINDS, includeExternalToolInput: false, }); const metadata = { agentTitle: input.agentTitle, cwd: input.cwd };