Merge remote-tracking branch 'origin/farm/bf21f607/frame-rewind-completion'

# Conflicts:
#	packages/coding-agent/src/session/agent-session.ts
#	packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts
This commit is contained in:
can1357
2026-07-02 03:56:59 +02:00
4 changed files with 283 additions and 10 deletions
+6 -4
View File
@@ -1723,10 +1723,8 @@ async function executeToolCalls(
const checkSteering = async (): Promise<void> => {
// `signal` (external/user abort) is checked separately from the internal
// abort controllers: once the run is externally aborted it is unwinding
// and the interrupt would be redundant. Once an IRC/steering interrupt has
// already fired, do not poll again — especially not through a consuming
// legacy steering getter.
if (!shouldInterruptImmediately || interruptState.triggered || signal?.aborted) {
// and the interrupt would be redundant.
if (!shouldInterruptImmediately || signal?.aborted) {
return;
}
// Mid-batch steering detection must be non-consuming. If a direct
@@ -1739,12 +1737,16 @@ async function executeToolCalls(
if (steeringQueued) {
// User steering upgrades an in-flight IRC interrupt: it aborts the
// shared signal so foreground tools stop as they do for a user Esc.
// Idempotent — a second steer poll after the abort is a no-op.
if (!steeringAbortController.signal.aborted) {
interruptState.triggered = true;
steeringAbortController.abort();
}
return;
}
// IRC only fires once: a peer interrupt already recorded on interruptState
// must not re-abort, and (unlike steering above) never re-consume a queue.
if (interruptState.triggered) return;
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
// Peer IRC only aborts interruptible waits: a foreground bash / write
// mid-execution keeps running so we never leave partial side effects.
+57
View File
@@ -18,6 +18,63 @@
- Fixed subagent HUD layout and tree rendering in the TUI to align correctly with other HUD panels
- Fixed session persistence corrupting Anthropic signed thinking blocks: the generic 500K-character truncation cap could shorten a large `thinking` string while leaving its cryptographic signature intact, so replaying or resuming the session sent a signature that no longer matched the text and the provider rejected it with an HTTP 400. Signed `thinking` blocks and encrypted `redactedThinking` blobs are now persisted verbatim (all-or-nothing); unsigned thinking and plain text remain truncatable for size control.
- Fixed post-rewind context to tell the agent the checkpoint completed and to make repeat `rewind` calls recover with guidance instead of a bare no-checkpoint error; the branch-scan rehydration now also restores an active checkpoint when a session resumes with the latest checkpoint not yet rewound, so `rewind` can complete instead of failing with "No active checkpoint". ([#4187](https://github.com/can1357/oh-my-pi/issues/4187))
- Fixed task.maxConcurrency being breachable when a queued spawn was cancelled: the spawn path could release a semaphore permit it never acquired, letting a later task start while the cap was saturated.
- Fixed session exit diagnostics recording signal and crash exits (SIGTERM, SIGHUP, uncaught exceptions) as a normal "dispose": the postmortem teardown now threads the real reason into session disposal.
- Fixed the subagent yield-label guard ignoring JTD discriminator (oneOf) output schemas, which let stale incremental labels pass into successful results when final validation was skipped after retries.
- Fixed grep/ast_grep search scopes rejecting `www.` and collapsed-scheme (`https:/host`) URL spellings that the read tool accepts; unresolvable URL-shaped scopes now fail with an explicit external-URL error instead of "Path not found".
- Fixed model discovery ignoring `NODE_EXTRA_CA_CERTS`: the model registry's default fetch now applies the extra-CA wrapper, so `/models` probes work behind private-CA gateways like provider chat requests.
- Fixed ctrl+p role-model cycling getting stuck on one transition and skipping every other role: a session-branch traversal regression returned entries leaf-to-root, so the cycle (and session model restore) read the oldest recorded model change instead of the newest.
- Fixed ctrl+p cycling from a stale slot after the model was switched through another surface (alt+m, /model, retry fallback): the recorded role is now trusted only while its resolved model is still the active model, falling back to matching by model.
- Fixed the apply_patch tool to prevent silently overwriting pre-existing files during creation or renaming, rejecting upfront with an error instead.
- Fixed multi-file apply_patch to stop at the first failing file, surface applied vs. skipped paths, and correctly report the error to the agent loop.
- Fixed process termination (SIGTERM, SIGHUP, uncaught exceptions) skipping editor draft saves, session shutdown events, and background job cleanup.
- Fixed /quit and /exit commands blocking session closure by introducing a shutdown budget and backgrounding remaining tasks.
- Fixed git and GitHub CLI subprocesses hanging on interactive prompts by forcing non-interactive environments, adding timeouts, and capping output.
- Fixed RPC mode abort_bash being blocked by running bash commands by dispatching bash in the background.
- Fixed task.maxConcurrency and task.maxRecursionDepth limits being bypassed by sub-spawn paths, ensuring limits are dynamically resized and respected.
- Fixed the edit tool inflating session files by pruning extremely large file snapshots from tool-result details.
- Fixed edit-tool Markdown list guidance so hashline parser errors and the model-facing prompt teach `+- item` escaping instead of steering agents toward full-file `write` fallbacks. ([#4179](https://github.com/can1357/oh-my-pi/issues/4179))
- Fixed workstation OS detection rendering "Kernel: unknown" on macOS 15+.
- Fixed /copy code and /copy cmd commands being treated as normal prompts instead of copying the requested blocks.
- Fixed interactive bash status line not updating after directory changes (cd).
- Fixed session title refreshes ignoring user TITLE_SYSTEM.md overrides during replans, and prevented auto-generated titles from incorrectly preserving all-caps text from user messages.
- Fixed the live todo HUD going stale during long tool-use loops by adding mid-run reminders for incomplete items.
- Fixed /shake and mid-stream chat rebuilds erasing active LLM output.
- Fixed RpcClient failing to restart after being stopped or failing on initial startup.
- Fixed /collab web guests being unable to answer ask tool questions by routing host UI requests through writable collab peers.
- Fixed search and AST tools accepting external read URLs by materializing fetched URL text through the read cache before path resolution.
- Fixed Tavily web search to retry without recency filters if no content is returned.
- Fixed extension validation failures for omp install pi-lean-ctx by exposing legacy tool factories.
- Fixed visibility of the focused option in the multi-select ask picker on certain color themes.
- Fixed TUI row overlapping and duplication in the eval tool's live subagent progress tree under heavy concurrency.
- Fixed session resumes after silent exits by recording pre-tool start markers and shutdown diagnostics.
- Fixed status-line redraw crashes when tool-call arguments contain BigInt values.
- Fixed terminal scrollback rows retaining old colors after theme switches.
- Fixed Esc key behavior in the TUI to clear unrecoverable input instead of preserving drafts.
- Fixed marketplace-installed plugins appearing redundantly in both the npm list and the extension status provider.
- Fixed parent and peer IRC message delivery delays.
- Fixed provider/model:auto entries in modelRoles collapsing to inherit and losing their auto state on reload.
- Fixed plan execution prompts to avoid embedding the full plan, referencing the local plan file instead.
- Fixed subagent live progress leaking raw tool output into the parent TUI.
- Fixed eval subagents with custom output schemas receiving stale incremental yield labels.
- Fixed hidden-thinking live status rows rendering as glyph-only lines by adding a persistent label.
- Fixed /compact summary divider placement to keep it in the live scrollable region.
- Fixed the time_spent status-line segment ticking continuously during idle sessions.
- Fixed browser tool schema validation to require the code argument for run calls.
- Improved robustness of MCP authentication error detection and header-based server discovery.
- Fixed reliable detection of 401/403 authorization failures during Smithery commands and HTTP RPCs.
- Improved streaming preview responsiveness for write, edit, and eval tools by decoding streamed string arguments incrementally.
- Added retry-path diagnostics for assistant-tail removal and scheduled continuations after transient provider errors.
- Fixed CJK history rendering issues across repeated compactions.
- Fixed user-invoked skills failing to identify themselves or resolve relative paths across various execution paths.
- Fixed type errors introduced by the merge sweep: restored the ask row-budget priority field, narrowed dereferenced schema property access, and updated stale test API usage.
- Fixed git clone and fetch being killed by the 5-minute local-command timeout; network transfers now use a separate 30-minute deadline, overridable per call.
- Fixed the TUI collab guest (omp join) silently dropping host ask/selector UI requests; they now present through the standard dialog flow and round-trip responses, with cancellation and resync replay handled.
- Fixed transcript rebuilds (theme change, /shake, focus replay) showing stale streamed write/edit/eval content by sharing the partial-JSON decode between the live streaming path and every rebuild path.
- Fixed an explicitly configured compaction.reserveTokens equal to the built-in default being silently replaced by the proportional small-window fallback; the setting now defaults to unset and explicit values are always honored.
- Fixed user-configured LiteLLM discovery providers keeping stale reseller display-name suffixes for up to 24 hours after upgrade by invalidating the warm model cache.
### Fixed
- Fixed several issues with the `apply_patch` and edit tools, including preventing dirty buffers on early aborts, rejecting overwrites of pre-existing files, stopping at the first failing file in multi-file operations, and pruning extremely large file snapshots to prevent session inflation.
- Fixed process termination (SIGTERM, SIGHUP, uncaught exceptions) to ensure editor drafts are saved, sessions shut down cleanly, and background jobs are cleaned up.
@@ -430,7 +430,9 @@ function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | u
return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
}
function isSuccessfulCheckpointEntry(entry: SessionEntry): boolean {
function isSuccessfulCheckpointEntry(entry: SessionEntry): entry is SessionMessageEntry & {
message: { role: "toolResult"; toolName: "checkpoint"; isError?: false };
} {
return (
entry.type === "message" &&
entry.message.role === "toolResult" &&
@@ -439,6 +441,16 @@ function isSuccessfulCheckpointEntry(entry: SessionEntry): boolean {
);
}
function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
if (!isSuccessfulCheckpointEntry(entry)) return undefined;
const details = entry.message.details;
if (details && typeof details === "object") {
const startedAt = stringProperty(details, "startedAt");
if (startedAt) return startedAt;
}
return entry.timestamp;
}
// A side-channel assistant response is signed for the hidden prompt/history that
// produced it. If we persist that response under a different user turn, native
// replay anchors become invalid; keep only visible, non-cryptographic content.
@@ -2166,7 +2178,7 @@ export class AgentSession {
this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
if (this.#advisorEnabled) this.#buildAdvisorRuntime();
this.#rehydrateLastCompletedRewind();
this.#rehydrateCheckpointRewindState();
// Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic)
@@ -6714,14 +6726,54 @@ export class AgentSession {
this.agent.setTools(activeTools);
}
#rehydrateLastCompletedRewind(): void {
#clearCheckpointRuntimeState(): void {
this.#checkpointState = undefined;
this.#pendingRewindReport = undefined;
this.#lastCompletedRewind = undefined;
this.#rewoundToolResultIds.clear();
}
/**
* Rebuild checkpoint/rewind runtime state from the current branch. Handles two
* cases surfaced by session resume, `switchSession()` reloading the same file,
* and tree navigation:
* - The branch's most recent checkpoint has already been rewound → restore
* `#lastCompletedRewind` so a repeat `rewind` call receives the
* "checkpoint already completed" recovery guidance.
* - The branch's most recent checkpoint has NOT been rewound (e.g. the run
* was aborted between `checkpoint` and `rewind`) → restore
* `#checkpointState` so the next `rewind` call can complete the
* checkpoint instead of failing with "No active checkpoint".
*/
#rehydrateCheckpointRewindState(): void {
this.#clearCheckpointRuntimeState();
let completed: CompletedRewindState | undefined;
let pending: { entryId: string; startedAt: string; messageCount: number } | undefined;
let messageCount = 0;
for (const entry of this.sessionManager.getBranch()) {
if (entry.type === "message") messageCount++;
if (isSuccessfulCheckpointEntry(entry)) {
completed = undefined;
pending = {
entryId: entry.id,
startedAt: checkpointStartedAtFromEntry(entry) ?? entry.timestamp,
messageCount,
};
continue;
}
completed = completedRewindFromEntry(entry) ?? completed;
const completedFromEntry = completedRewindFromEntry(entry);
if (completedFromEntry) {
completed = completedFromEntry;
pending = undefined;
}
}
if (pending) {
this.#checkpointState = {
checkpointEntryId: pending.entryId,
startedAt: pending.startedAt,
checkpointMessageCount: pending.messageCount,
};
return;
}
this.#lastCompletedRewind = completed;
}
@@ -8304,6 +8356,7 @@ export class AgentSession {
await this.sessionManager.flush();
}
await this.sessionManager.newSession(options);
this.#clearCheckpointRuntimeState();
this.setTodoPhases([]);
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
@@ -9715,6 +9768,7 @@ export class AgentSession {
await this.sessionManager.flush();
this.#cancelOwnAsyncJobs();
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
this.#clearCheckpointRuntimeState();
// agent.reset() clears the core steering/follow-up queues. Preserve any queued
// steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
// pre-loader TUI steer) so they survive into the post-handoff session instead of
@@ -13970,7 +14024,14 @@ export class AgentSession {
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
: undefined;
// Snapshot the full checkpoint runtime state: the success path calls
// #rehydrateCheckpointRewindState(), which clears and rebuilds all four
// fields from the target branch. On rollback every one must be restored,
// or a failed switch leaks the target session's checkpoint state.
const previousCheckpointState = this.#checkpointState;
const previousPendingRewindReport = this.#pendingRewindReport;
const previousLastCompletedRewind = this.#lastCompletedRewind;
const previousRewoundToolResultIds = new Set(this.#rewoundToolResultIds);
this.agent.clearAllQueues();
this.#pendingNextTurnMessages = [];
@@ -13991,7 +14052,7 @@ export class AgentSession {
this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
this.#rehydrateLastCompletedRewind();
this.#rehydrateCheckpointRewindState();
// Emit session_switch event to hooks
if (this.#extensionRunner) {
@@ -14133,7 +14194,10 @@ export class AgentSession {
this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages);
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
this.#checkpointState = previousCheckpointState;
this.#pendingRewindReport = previousPendingRewindReport;
this.#lastCompletedRewind = previousLastCompletedRewind;
this.#rewoundToolResultIds = previousRewoundToolResultIds;
if (previousModel) {
this.agent.setModel(previousModel);
}
@@ -14202,6 +14266,7 @@ export class AgentSession {
} else {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
this.#rehydrateCheckpointRewindState();
this.#syncTodoPhasesFromBranch();
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
@@ -14288,6 +14353,7 @@ export class AgentSession {
this.#cancelOwnAsyncJobs();
this.sessionManager.createBranchedSession(leafId);
this.#rehydrateCheckpointRewindState();
this.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: question }],
@@ -14484,7 +14550,7 @@ export class AgentSession {
const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
await this.#restoreMCPSelectionsForSessionContext(displayContext);
this.agent.replaceMessages(displayContext.messages);
this.#rehydrateLastCompletedRewind();
this.#rehydrateCheckpointRewindState();
this.#resetAdvisorSessionState();
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
@@ -131,6 +131,21 @@ function createToolSession(overrides: Partial<ToolSession> = {}): ToolSession {
};
}
function rewindToolForSession(session: AgentSession): RewindTool {
return new RewindTool(
createToolSession({
getCheckpointState: () => session.getCheckpointState(),
getLastCompletedRewind: () => session.getLastCompletedRewind(),
}),
);
}
async function expectNoActiveCheckpointError(session: AgentSession): Promise<void> {
await expect(rewindToolForSession(session).execute("repeat_rewind", { report: "retry" })).rejects.toThrow(
"No active checkpoint. Create a checkpoint before calling rewind.",
);
}
describe("AgentSession checkpoint rewind branch context", () => {
it("rebuilds active history through branch_summary before the post-rewind assistant turn", async () => {
const report = "findings: kept checkpoint; risks: stale signed thinking";
@@ -256,6 +271,59 @@ describe("AgentSession checkpoint rewind branch context", () => {
);
});
it("clears completed rewind state when starting a new session", async () => {
const harness = await createHarness([
{
content: [{ type: "toolCall", id: "call_checkpoint", name: "checkpoint", arguments: { goal: "inspect" } }],
stopReason: "toolUse",
},
{
content: [{ type: "toolCall", id: "call_rewind", name: "rewind", arguments: { report: "findings" } }],
stopReason: "toolUse",
},
{
content: ["DONE"],
stopReason: "stop",
},
]);
await harness.session.prompt("investigate with a checkpoint");
expect(harness.session.getLastCompletedRewind()).toBeDefined();
await harness.session.newSession();
expect(harness.session.getLastCompletedRewind()).toBeUndefined();
await expectNoActiveCheckpointError(harness.session);
});
it("rehydrates completed rewind state from the branched path", async () => {
const harness = await createHarness([
{
content: [{ type: "toolCall", id: "call_checkpoint", name: "checkpoint", arguments: { goal: "inspect" } }],
stopReason: "toolUse",
},
{
content: [{ type: "toolCall", id: "call_rewind", name: "rewind", arguments: { report: "findings" } }],
stopReason: "toolUse",
},
{
content: ["DONE"],
stopReason: "stop",
},
]);
await harness.session.prompt("investigate with a checkpoint");
expect(harness.session.getLastCompletedRewind()).toBeDefined();
const userEntry = harness.session.sessionManager
.getEntries()
.find(entry => entry.type === "message" && entry.message.role === "user");
if (!userEntry) throw new Error("Expected user entry for branch");
await harness.session.branch(userEntry.id);
expect(harness.session.getLastCompletedRewind()).toBeUndefined();
await expectNoActiveCheckpointError(harness.session);
});
it("tells the model to continue when rewind is repeated after completion", async () => {
const tool = new RewindTool(
createToolSession({
@@ -271,4 +339,84 @@ describe("AgentSession checkpoint rewind branch context", () => {
"Checkpoint already completed; continue from the retained rewind report instead of calling rewind again.",
);
});
it("rehydrates active checkpoint state when resuming a session with no rewind yet", async () => {
const startedAt = "2026-01-01T00:00:00.000Z";
const harness = await createHarness([
{
content: [{ type: "toolCall", id: "call_checkpoint", name: "checkpoint", arguments: { goal: "inspect" } }],
stopReason: "toolUse",
},
{
content: [{ type: "toolCall", id: "call_rewind", name: "rewind", arguments: { report: "findings" } }],
stopReason: "toolUse",
},
{
content: ["DONE"],
stopReason: "stop",
},
]);
await harness.session.prompt("investigate with a checkpoint");
const originalCompleted = harness.session.getLastCompletedRewind();
expect(originalCompleted).toBeDefined();
// Simulate "run aborted between checkpoint and rewind" by branching to the
// checkpoint entry itself — the rewind and its rewind-report entry drop off
// the active branch, leaving the checkpoint tool result as the leaf.
const branch = harness.session.sessionManager.getBranch();
const checkpointEntry = branch.find(
entry =>
entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "checkpoint",
);
if (!checkpointEntry) throw new Error("Expected checkpoint tool result entry");
harness.session.sessionManager.branch(checkpointEntry.id);
const reloadedMock = createMockModel({ responses: [] });
const reloadedSettings = Settings.isolated({
"compaction.enabled": false,
"retry.enabled": false,
"todo.enabled": false,
"todo.eager": "default",
"todo.reminders": false,
});
reloadedSettings.setModelRole("default", `${reloadedMock.provider}/${reloadedMock.id}`);
const reloadedTools = [checkpointTool as AgentTool, rewindTool as AgentTool];
const reloadedAgent = new Agent({
getApiKey: () => "test-key",
initialState: {
model: reloadedMock,
systemPrompt: ["Test"],
tools: reloadedTools,
messages: harness.session.sessionManager.buildSessionContext().messages,
},
convertToLlm,
streamFn: reloadedMock.stream,
});
const reloadedSession = new AgentSession({
agent: reloadedAgent,
sessionManager: harness.session.sessionManager,
settings: reloadedSettings,
modelRegistry: new ModelRegistry(
harness.authStorage,
path.join(harness.tempDir.path(), "models-reloaded.yml"),
),
toolRegistry: new Map(reloadedTools.map(tool => [tool.name, tool])),
});
harness.extraSessions.push(reloadedSession);
const restored = reloadedSession.getCheckpointState();
expect(restored).toBeDefined();
expect(restored?.checkpointEntryId).toBe(checkpointEntry.id);
expect(restored?.startedAt).toBe(startedAt);
expect(reloadedSession.getLastCompletedRewind()).toBeUndefined();
// The rewind tool must accept the request now that the active checkpoint
// has been re-hydrated — previously this threw "No active checkpoint".
const rewindResult = await rewindToolForSession(reloadedSession).execute("call_rewind_after_resume", {
report: "post-resume findings",
});
expect(rewindResult.content.some(part => part.type === "text" && part.text.includes("Rewind requested"))).toBe(
true,
);
});
});