diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 827bdc8e0..0e5da2ea6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -6396,6 +6396,7 @@ export class AgentSession { async fork(): Promise { this.#assertVibeSessionTransitionAllowed("fork the session"); const previousSessionFile = this.sessionFile; + const previousSessionId = this.sessionManager.getSessionId(); // Emit session_before_switch event with reason "fork" (can be cancelled) if (this.#extensionRunner?.hasHandlers("session_before_switch")) { @@ -6434,6 +6435,9 @@ export class AgentSession { } this.#bash.markSessionTransition(bashTransition); this.#bash.finishSessionTransition(bashTransition, true); + // The fork clones the transcript and keeps this recovery state running + // under a fresh id, so the work already produced is still this session's. + this.#recovery.reanchorServedAttribution(previousSessionId); // Copy artifacts directory if it exists const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6); diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index d76d07b46..ae9577254 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -284,6 +284,29 @@ export class TurnRecovery { return value; } + /** + * Carries attribution onto a new session id that continues this conversation. + * + * The session-id anchor assumes a new id means an unrelated transcript, which + * holds for `/new` and for resuming something else. A fork breaks that + * assumption on purpose: it clones the transcript and keeps running the same + * recovery state under a fresh id. Dropping attribution there would bootstrap + * an unproven fallback as the primary and re-credit it with the work the + * previous model did — the very bug the anchor exists to prevent. + * + * Only state belonging to `previousSessionId` moves, so an id left behind by + * an earlier switch stays expired. + */ + reanchorServedAttribution(previousSessionId: string): void { + const sessionId = this.#host.sessionManager.getSessionId(); + if (this.#lastServed?.sessionId === previousSessionId) { + this.#lastServed = { ...this.#lastServed, sessionId }; + } + if (this.#fallbackRoutedFor === previousSessionId) { + this.#fallbackRoutedFor = sessionId; + } + } + /** Resets per-prompt recovery counters and terminal-stop acceptance. */ resetForNewPrompt(): void { this.#emptyStopRetryCount = 0; diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index c61863aac..e5784c981 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -4296,6 +4296,44 @@ describe("AgentSession retry fallback", () => { }); }); + it("carries attribution across a fork, which continues the conversation under a new id", async () => { + using tempDir = TempDir.createSync("@omp-fallback-fork-"); + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.fallbackChains": { default: [`${fallbackModel.provider}/${fallbackModel.id}`] }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + + const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry }); + + await session.prompt("Fail over to the fallback"); + await session.waitForIdle(); + const served = { + selector: `${fallbackModel.provider}/${fallbackModel.id}`, + isFallback: true, + }; + expect(session.servingModel).toEqual(served); + + const sessionIdBeforeFork = sessionManager.getSessionId(); + expect(await session.fork()).toBe(true); + expect(sessionManager.getSessionId()).not.toBe(sessionIdBeforeFork); + + // A fork clones the transcript and keeps running the same session, so the + // work the fallback produced is still this session's — unlike a switch to + // an unrelated transcript, which expires it. + expect(session.servingModel).toEqual(served); + }); + it("keeps attribution on a served fallback while the next candidate is unproven", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const firstFallback = getBundledModel("openai", "gpt-4o-mini");