Rewind chat or files from any user message (#1154)

* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
Mohamed Boudra
2026-05-25 17:11:34 +08:00
committed by GitHub
parent e966f70322
commit 132e572d6e
99 changed files with 7106 additions and 3425 deletions
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "only-user",
text: "first turn",
timestamp: new Date(500),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
expect(result.tail).toEqual([]);
expect(getUserTexts(result.tail)).toHaveLength(0);
});
it("performs bootstrap tail init with catch-up side effect", () => {
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
expect(result.sideEffects).toEqual([]);
});
it("resets visible timeline when a new epoch starts at seq 1", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const currentTail = [makeAssistantItem("old timeline")];
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("rewound start", "user_message"),
seq: 1,
epoch: "epoch-2",
currentCursor: existingCursor,
currentTail,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-2",
startSeq: 1,
endSeq: 1,
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getUserTexts(result.tail)).toEqual(["rewound start"]);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
timestamp: Date;
}
interface TimelineSequencingGateResult {
shouldApplyStreamEvent: boolean;
nextTimelineCursor: TimelineCursor | null;
cursorChanged: boolean;
resetLiveTimeline: boolean;
sideEffects: AgentStreamReducerSideEffect[];
}
export interface AgentStreamReducerAgentSnapshot {
status: AgentLifecycleStatus;
updatedAt: Date;
@@ -672,61 +680,91 @@ function applyAgentPatch(
};
}
function processTimelineSequencingGate(input: {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentCursor: TimelineCursor | undefined;
}): TimelineSequencingGateResult {
const { event, seq, epoch, currentCursor } = input;
const base: TimelineSequencingGateResult = {
shouldApplyStreamEvent: true,
nextTimelineCursor: null,
cursorChanged: false,
resetLiveTimeline: false,
sideEffects: [],
};
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
return base;
}
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
};
}
if (decision === "accept") {
return {
...base,
nextTimelineCursor: {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
},
cursorChanged: true,
};
}
if (decision === "gap") {
return {
...base,
shouldApplyStreamEvent: false,
sideEffects: currentCursor
? [
{
type: "catch_up",
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
},
]
: [],
};
}
if (decision === "drop_epoch" && seq === 1) {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
resetLiveTimeline: true,
};
}
return {
...base,
shouldApplyStreamEvent: false,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
let shouldApplyStreamEvent = true;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
}
}
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
event,
timestamp,
source: "live",
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
head,
changedTail,
changedHead,
cursor: nextTimelineCursor,
cursorChanged,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
agent: agentPatch,
agentChanged,
sideEffects,
sideEffects: sequencing.sideEffects,
};
}