From 9d1fb7d7bbb768ce8ab8a7f7bb10fe1d64507b24 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 15:17:08 +0200 Subject: [PATCH 1/2] fix(threads): replay the thread's tail after a provider switch (#1493) setProvider dropped the old CLI's session without setting replayContext, so the next run (and every quota failover, which routes through it) started with no history. Any session drop in setProvider now sets replayContext, reusing the #254 rewind digest of the thread's own retained tail. Co-Authored-By: Claude Opus 5.5 --- electron/services-threads.js | 9 +- electron/test/provider-switch-context.test.js | 224 ++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 electron/test/provider-switch-context.test.js diff --git a/electron/services-threads.js b/electron/services-threads.js index ee634b33c..20845b244 100644 --- a/electron/services-threads.js +++ b/electron/services-threads.js @@ -538,7 +538,7 @@ function setProvider(store, input) { const providerChanging = providerProvided && String(input.provider) !== String(thread.provider); - /** @type {{ provider?: string, model?: string | null, sessionId?: null, reasoningEffort?: null, webSearch?: boolean }} */ + /** @type {{ provider?: string, model?: string | null, sessionId?: null, replayContext?: boolean, reasoningEffort?: null, webSearch?: boolean }} */ const patch = {}; if (providerChanging && thread.status === "working") { @@ -612,6 +612,13 @@ function setProvider(store, input) { } } + if (patch.sessionId === null) { + // The new CLI (or re-pinned model) starts with no memory of this thread. + // Replay its own retained tail on the next send, same as a rewind (#254), + // or a switch mid-task (and quota failover) restarts blind (#1493). + patch.replayContext = true; + } + const updated = store.updateThread(threadId, patch); store.save(); const row = updated || { ...thread, ...patch }; diff --git a/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js new file mode 100644 index 000000000..a3e748374 --- /dev/null +++ b/electron/test/provider-switch-context.test.js @@ -0,0 +1,224 @@ +"use strict"; + +/** + * Issue #1493: a provider switch (manual or quota failover) drops the old + * CLI's session, so the next run must replay the thread's own tail instead + * of starting with no history. + */ + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const { execFileSync } = require("node:child_process"); +const { Store } = require("../store.js"); +const services = require("../services.js"); +const { getProvider } = require("../providers.js"); +const { createRunner } = require("../runner.js"); +const { rmTree } = require("./support/rmTree.js"); + +function git(cwd, args) { + execFileSync("git", args, { cwd, stdio: "ignore" }); +} + +async function loadCore() { + const corePath = path.join(__dirname, "../../core/dist/index.js"); + return import(pathToFileURL(corePath).href); +} + +function waitFor(predicate, { timeoutMs = 15000, intervalMs = 20 } = {}) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + try { + if (predicate()) return resolve(); + } catch (e) { + return reject(e); + } + if (Date.now() - start > timeoutMs) { + return reject(new Error("waitFor timed out")); + } + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +/** + * Generic agent (CODER_AGENT_CMD gets the prompt as its last argv): logs each + * prompt as a JSON line, fails the first call with `firstError` when given. + * Hex + eval because parseAgentCommand splits on spaces. + */ +function agentCmd(logFile, firstError) { + const src = ` +const fs = require("fs"); +const log = ${JSON.stringify(logFile)}; +const first = !fs.existsSync(log); +fs.appendFileSync(log, JSON.stringify(process.argv[process.argv.length - 1]) + "\\n"); +if (first && ${JSON.stringify(firstError || "")}) { + process.stderr.write(${JSON.stringify(firstError || "")}); + process.exit(1); +} +process.stdout.write("ok"); +`; + const hex = Buffer.from(src, "utf8").toString("hex"); + return `${process.execPath} -e eval(Buffer.from('${hex}','hex').toString())`; +} + +function readPrompts(logFile) { + return fs + .readFileSync(logFile, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); +} + +describe("provider switch keeps context (#1493)", () => { + let tmpDir; + let store; + let runner; + let project; + let logFile; + let prevSimulate; + let prevAgentCmd; + + beforeEach(async () => { + prevSimulate = process.env.CODER_SIMULATE; + prevAgentCmd = process.env.CODER_AGENT_CMD; + delete process.env.CODER_SIMULATE; + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-switch-ctx-")); + logFile = path.join(tmpDir, "prompts.jsonl"); + store = new Store(path.join(tmpDir, "store.json")); + runner = createRunner({ + store, + core: await loadCore(), + pushFn: () => {}, + tickMs: 15, + }); + const repo = path.join(tmpDir, "app"); + fs.mkdirSync(repo); + git(repo, ["init"]); + project = await services.addProject(store, repo); + }); + + afterEach(async () => { + if (runner) runner.stopAll(); + await rmTree(tmpDir); + if (prevSimulate === undefined) delete process.env.CODER_SIMULATE; + else process.env.CODER_SIMULATE = prevSimulate; + if (prevAgentCmd === undefined) delete process.env.CODER_AGENT_CMD; + else process.env.CODER_AGENT_CMD = prevAgentCmd; + }); + + /** A claude thread mid-task: one finished turn and a live session. */ + function threadWithHistory() { + const thread = services.createThread(store, { + projectId: project.id, + title: "Mid-task", + }); + store.setMessages(thread.id, [ + { id: "u1", role: "user", text: "refactor the parser", createdAt: 1 }, + { id: "a1", role: "assistant", text: "PARSER HALF DONE", createdAt: 2 }, + ]); + store.updateThread(thread.id, { sessionId: "sess-claude" }); + store.saveNow(); + return store.getThread(thread.id); + } + + it("manual switch: next run's prompt carries the thread's own tail", async () => { + const thread = threadWithHistory(); + const switched = services.setProvider(store, { + threadId: thread.id, + provider: "codex", + }); + assert.equal(switched.sessionId, null); + assert.equal(store.getThread(thread.id).replayContext, true); + + process.env.CODER_AGENT_CMD = agentCmd(logFile); + await runner.startRun({ threadId: thread.id, prompt: "keep going" }); + await waitFor(() => !runner.isRunning(thread.id)); + + const [prompt] = readPrompts(logFile); + assert.ok(prompt.startsWith("[Hand-off context:"), prompt.slice(0, 80)); + assert.ok(prompt.includes("PARSER HALF DONE")); + assert.ok(prompt.includes("[End context]\n\nkeep going")); + // One-shot: cleared once the run starts. + assert.ok(!store.getThread(thread.id).replayContext); + const users = store.getMessages(thread.id).filter((m) => m.role === "user"); + assert.equal(users[users.length - 1].text, "keep going"); + }); + + it("quota failover: the resumed run on the new provider carries the tail", async () => { + const thread = threadWithHistory(); + services.setSettings(store, { quotaFailover: ["grok"] }); + process.env.CODER_AGENT_CMD = agentCmd( + logFile, + "account quota or balance is exhausted. Please top up.", + ); + await runner.startRun({ threadId: thread.id, prompt: "keep going" }); + await waitFor( + () => + fs.existsSync(logFile) && + readPrompts(logFile).length >= 2 && + !runner.isRunning(thread.id), + ); + + const [before, after] = readPrompts(logFile); + // The original session was live, so the first attempt needed no prefix. + assert.ok(before.startsWith("keep going"), before.slice(0, 80)); + assert.equal(store.getThread(thread.id).provider, "grok"); + assert.ok(after.startsWith("[Hand-off context:"), after.slice(0, 80)); + assert.ok(after.includes("PARSER HALF DONE")); + assert.ok(after.includes("[End context]\n\nkeep going")); + assert.ok(!store.getThread(thread.id).replayContext); + }); + + it("brand-new thread: switch sets no replay and the run has no prefix", async () => { + const thread = services.createThread(store, { + projectId: project.id, + title: "Fresh", + }); + services.setProvider(store, { threadId: thread.id, provider: "codex" }); + assert.ok(!store.getThread(thread.id).replayContext); + + process.env.CODER_AGENT_CMD = agentCmd(logFile); + await runner.startRun({ threadId: thread.id, prompt: "hello" }); + await waitFor(() => !runner.isRunning(thread.id)); + const prompts = readPrompts(logFile); + assert.equal(prompts.length, 1); + assert.ok(prompts[0].startsWith("hello"), prompts[0].slice(0, 80)); + }); + + it("model switch on a session-pinning provider replays too", () => { + const codex = getProvider("codex"); + const prev = codex.sessionPinsModel; + codex.sessionPinsModel = true; + try { + const thread = threadWithHistory(); + services.setProvider(store, { threadId: thread.id, provider: "codex" }); + store.updateThread(thread.id, { + sessionId: "sess-codex", + replayContext: false, + }); + const other = codex.models.find( + (m) => (typeof m === "string" ? m : m.id) !== store.getThread(thread.id).model, + ); + services.setProvider(store, { + threadId: thread.id, + model: typeof other === "string" ? other : other.id, + }); + const t = store.getThread(thread.id); + assert.equal(t.sessionId, null); + assert.equal(t.replayContext, true); + assert.match( + services.buildHandoffPrefix(t, (id) => store.getMessages(id)), + /PARSER HALF DONE/, + ); + } finally { + codex.sessionPinsModel = prev; + } + }); +}); From 4dff0dd03a186a8eff4447f0dc14408156802426 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 15:17:08 +0200 Subject: [PATCH 2/2] fix(store): replay context when a worktree move drops the session (#1493) Co-Authored-By: Claude Opus 5.5 --- electron/store.js | 3 +++ electron/test/provider-switch-context.test.js | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/electron/store.js b/electron/store.js index a3bcd7363..6a27fab4a 100644 --- a/electron/store.js +++ b/electron/store.js @@ -2352,6 +2352,9 @@ class Store { !Object.prototype.hasOwnProperty.call(patch, "sessionId") ) { p = { ...patch, sessionId: null }; + // Same rule as setProvider (#1493): a dropped session replays the + // thread's own tail so the next turn is not blind. + if (t.sessionId) p.replayContext = true; } // Snapshot the rollout model on first sessionId (or a replacement // id). Codex exec resume ignores later picker changes (#1215). diff --git a/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js index a3e748374..b071f1262 100644 --- a/electron/test/provider-switch-context.test.js +++ b/electron/test/provider-switch-context.test.js @@ -221,4 +221,12 @@ describe("provider switch keeps context (#1493)", () => { codex.sessionPinsModel = prev; } }); + + it("moving a session-bearing thread to a new worktree replays too", () => { + const thread = threadWithHistory(); + store.updateThread(thread.id, { worktreePath: path.join(tmpDir, "wt") }); + const t = store.getThread(thread.id); + assert.equal(t.sessionId, null); + assert.equal(t.replayContext, true); + }); });