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 };