diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index c755eab588..33e56901d7 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -83,4 +83,47 @@ describe("resolveAssistantTurnForkBoundary", () => { boundaryCursor: { epoch: "timeline-1", seq: 42 }, }); }); + + it("includes the provider message id with a supported timeline cursor", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + boundaryMessageId: "msg-assistant-1", + }); + }); + + it("falls back to the provider message id when timeline cursors are unsupported", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toEqual({ boundaryMessageId: "msg-assistant-1" }); + }); + + it("does not offer an unavailable boundary", () => { + expect( + resolveAssistantTurnForkBoundary({ + items: [assistantMessage("assistant-1", 2)], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index f82d91297d..2f76fe635c 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -398,8 +398,10 @@ second line'`, it("selects a synthetic assistant error by its timeline cursor", () => { const result = buildAgentForkContextAttachment({ - timelineEpoch: "timeline-1", - boundaryCursor: { epoch: "timeline-1", seq: 2 }, + cursorBoundary: { + timelineEpoch: "timeline-1", + cursor: { epoch: "timeline-1", seq: 2 }, + }, rows: [ row(1, { type: "user_message", text: "Try the task", messageId: "user-1" }), row(2, { type: "assistant_message", text: "[System Error] provider failed" }), @@ -417,6 +419,18 @@ second line'`, expect(result.attachment.text).not.toContain("This belongs to a later turn."); }); + it("rejects a cursor from a previous timeline epoch", () => { + expect(() => + buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-2", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [row(2, { type: "assistant_message", text: "Stale result." })], + }), + ).toThrow("Selected timeline position is no longer available."); + }); + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { expect(() => buildAgentForkContextAttachment({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 60e676d5c2..da3318c1a3 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -216,17 +216,21 @@ export function curateAgentActivity( : "No activity to display."; } +interface ForkCursorBoundary { + timelineEpoch: string; + cursor: { epoch: string; seq: number }; +} + function selectForkContextRows(input: { rows: readonly AgentTimelineRow[]; - timelineEpoch?: string; - boundaryCursor?: { epoch: string; seq: number } | null; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; }): { items: AgentTimelineItem[]; boundaryCursor: { epoch: string; seq: number } | null; boundaryMessageId: string | null; } { - const boundaryCursor = input.boundaryCursor ?? null; + const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; if (!boundaryCursor && !boundaryMessageId) { const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); @@ -237,7 +241,10 @@ function selectForkContextRows(input: { }; } - if (boundaryCursor && boundaryCursor.epoch !== input.timelineEpoch) { + if ( + input.cursorBoundary && + input.cursorBoundary.cursor.epoch !== input.cursorBoundary.timelineEpoch + ) { throw new Error("Selected timeline position is no longer available."); } const boundaryIndex = boundaryCursor @@ -286,8 +293,7 @@ function buildForkContextText(input: { export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; - timelineEpoch?: string; - boundaryCursor?: { epoch: string; seq: number } | null; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; agentTitle?: string | null; cwd?: string | null; @@ -299,8 +305,7 @@ export function buildAgentForkContextAttachment(input: { } { const selected = selectForkContextRows({ rows: input.rows, - timelineEpoch: input.timelineEpoch, - boundaryCursor: input.boundaryCursor, + cursorBoundary: input.cursorBoundary, boundaryMessageId: input.boundaryMessageId, }); const entries = curateProjectedActivityEntries(selected.items, { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 644eb21a94..01e2f9a506 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5480,8 +5480,9 @@ export class Session { }); const forkContext = buildAgentForkContextAttachment({ rows: timeline.rows, - timelineEpoch: timeline.epoch, - boundaryCursor: msg.boundaryCursor, + cursorBoundary: msg.boundaryCursor + ? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor } + : null, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, cwd: snapshot.cwd,