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 <noreply@anthropic.com>
This commit is contained in:
Monarch Maisuriya
2026-11-04 09:02:25 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bfbd4d5175
commit 6221b0c8b7
3 changed files with 47 additions and 5 deletions
+4 -3
View File
@@ -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
@@ -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: {
@@ -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 };