fix(chat): tighten fork boundary handling

This commit is contained in:
Mohamed Boudra
2026-07-14 14:53:39 +02:00
parent 162c7ce127
commit c554512348
4 changed files with 75 additions and 12 deletions
@@ -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();
});
});
@@ -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({
@@ -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, {
+3 -2
View File
@@ -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,