Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion electron/services-threads.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Comment on lines +615 to +619

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the current prompt from quota-failover replay

On automatic quota failover, the first attempt has already appended the current user message before setProvider enables replayContext; the resumed startRun then builds the replay tail from that transcript and separately appends the same prompt to the CLI input. Consequently every session-bearing failover sends the request twice (user: keep going inside the hand-off block and keep going again afterward), which can cause duplicated actions and displaces older messages from the capped replay tail. The replay path should omit the in-flight user message when fromQuotaFailover is set.

Useful? React with 👍 / 👎.

}

const updated = store.updateThread(threadId, patch);
store.save();
const row = updated || { ...thread, ...patch };
Expand Down
3 changes: 3 additions & 0 deletions electron/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
232 changes: 232 additions & 0 deletions electron/test/provider-switch-context.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
"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;
}
});

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);
});
});
Loading