From 65ee481aa2fb8b057937d4e6cc82339b1142c8a9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 16 Aug 2026 01:54:10 +0200 Subject: [PATCH] fix(agent): delivered checkpoint reminder to active loop --- .../coding-agent/src/session/agent-session.ts | 35 +++++++------------ ...t-session-checkpoint-rewind-branch.test.ts | 10 ++++-- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 227a3004b..cc8278596 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2344,11 +2344,11 @@ export class AgentSession { /** * Builds the transient checkpoint-active reminder for a successful - * checkpoint tool result, or undefined otherwise. The reminder is appended - * to agent.state synchronously in the message_end handler (before any - * await) so the next provider call within the same tool loop sees it, and - * is persisted via #persistMessageEnd. Because the entry sits after the - * checkpoint entry, the rewind branch cut drops it from the active path. + * checkpoint tool result, or undefined otherwise. The reminder is queued as + * steering synchronously in the message_end handler (before any await), so + * the agent loop folds it into the next provider call and persists it through + * its normal custom-message event. Because the entry sits after the checkpoint + * entry, the rewind branch cut drops it from the active path. */ #checkpointActiveReminderFor( message: AgentMessage, @@ -2527,14 +2527,12 @@ export class AgentSession { this.agent.appendMessage(interruptedThinkingMessage); } - // Same pre-await visibility requirement as the interrupted-thinking - // message: agent-core invokes message_end listeners fire-and-forget, so - // the next provider call can start before any awaited session-event - // emission or persistence below settles. The checkpoint-active reminder - // must already be in agent.state for that call — append it synchronously - // here and persist it later (see #persistMessageEnd). It sits after the - // checkpoint entry, so the rewind branch cut - // (branchWithSummary(checkpointEntryId)) drops it from the active path + // message_end listeners are fire-and-forget, and the agent loop runs against + // a context cloned at prompt start. Appending only to agent.state would not + // reach the next provider call in this tool loop. Queue the reminder as + // steering before any await so the loop drains it at the next step boundary; + // its normal custom-message event persists it after the checkpoint entry, + // allowing the rewind branch cut to drop it from the active path. const checkpointReminder = event.type === "message_end" && event.message.role === "toolResult" ? this.#checkpointActiveReminderFor(event.message) @@ -2555,7 +2553,7 @@ export class AgentSession { }; this.#pendingRewindReport = undefined; this.#lastCompletedRewind = undefined; - this.agent.appendMessage(checkpointReminder); + this.agent.steer(checkpointReminder); } const messageEndPersistence = @@ -2677,15 +2675,6 @@ export class AgentSession { interruptedThinkingMessage.attribution, ); } - if (checkpointReminder) { - this.sessionManager.appendCustomMessageEntry( - checkpointReminder.customType, - checkpointReminder.content, - checkpointReminder.display, - checkpointReminder.details, - checkpointReminder.attribution, - ); - } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere if (event.message.role === "assistant") { diff --git a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts index 3b3b136e8..7a47854ed 100644 --- a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts +++ b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts @@ -301,9 +301,10 @@ describe("AgentSession checkpoint rewind branch context", () => { while ( !session.messages.some( message => message.role === "custom" && message.customType === "checkpoint-active-reminder", - ) + ) || + mock.calls.length < 2 ) { - if (Date.now() > deadline) throw new Error("checkpoint-active-reminder never appeared"); + if (Date.now() > deadline) throw new Error("checkpoint reminder/provider call never appeared"); await Bun.sleep(10); } const reminder = session.messages.find( @@ -312,6 +313,11 @@ describe("AgentSession checkpoint rewind branch context", () => { ); expect(reminder).toBeDefined(); expect(reminder?.content).toContain("MUST `rewind` before yielding"); + const activeCall = mock.calls[1]; + expect(activeCall).toBeDefined(); + expect( + activeCall?.context.messages.some(message => messageText(message).includes("Exploration checkpoint active.")), + ).toBe(true); // #checkpointState is set synchronously with the reminder (pre-await), so an // immediate rewind would find an active checkpoint, not "No active checkpoint". expect(session.getCheckpointState()).toBeDefined();