diff --git a/electron/runner-permissions.js b/electron/runner-permissions.js index 9145e74b..2fc8ac2b 100644 --- a/electron/runner-permissions.js +++ b/electron/runner-permissions.js @@ -847,10 +847,9 @@ function createPermissions(ctx) { const switchNote = before ? planActSwitchNote(getProvider(before.provider), before) : null; - if (switchNote && typeof e.handle.setModel === "function") { - e.handle.setModel(before.model || null); - e.handle.modelSwitchedTo = before.model || null; - } + const modelSwitched = switchNote && typeof e.handle.setModel === "function" + ? e.handle.setModel(before.model || null) !== false : false; + if (modelSwitched) e.handle.modelSwitchedTo = before.model || null; e.handle.respond(pending.id, response); if (isPlan) planPromptHandled.add(threadId); if (isPlan && decision !== "deny") { @@ -887,7 +886,15 @@ function createPermissions(ctx) { ? `Allowed for session: ${pending.summary}${ruleNote}` : `Allowed: ${pending.summary}`; appendMessage(threadId, "event", label, e.runId); - if (switchNote) appendMessage(threadId, "event", switchNote, e.runId); + if (switchNote && modelSwitched) { + // set_model changes this live run without creating a new runId. Keep + // its original effort: the control message only changes the model. + const configuration = e.runConfiguration + ? { ...e.runConfiguration, model: before.model || null } : null; + appendMessage(threadId, "event", switchNote, e.runId, null, null, + configuration ? { runConfiguration: configuration } : null); + if (configuration) e.runConfiguration = configuration; + } if (redirectable) { if (redirectInline) { // Same turn, same runId: the model reads the redirect off the @@ -1185,10 +1192,6 @@ function createPermissions(ctx) { "event", approved ? "Plan approved" : "Plan rejected", ); - const switchNote = approved - ? planActSwitchNote(getProvider(thread.provider), thread) - : null; - if (switchNote) appendMessage(threadId, "event", switchNote); // No live CLI to hear the notes: they go first in line as the next // turn, still in plan mode, and the drain below sends them. if (!approved && feedback) { @@ -1249,11 +1252,6 @@ function createPermissions(ctx) { ? approved ? "Plan approved" : "Plan rejected" : `${approved ? "Allowed" : "Denied"} after restart: ${card.summary}`, ); - const switchNote = - approved && card.plan - ? planActSwitchNote(getProvider(thread.provider), thread) - : null; - if (switchNote) appendMessage(threadId, "event", switchNote); if (line) { services.restoreQueuedHead(store, { threadId, taken: { prompt: line, items: [line] } }); } diff --git a/electron/runner-provider-claude.js b/electron/runner-provider-claude.js index 2c01eb92..cc6c18f4 100644 --- a/electron/runner-provider-claude.js +++ b/electron/runner-provider-claude.js @@ -596,6 +596,16 @@ function createClaudeRun(ctx) { if (abortIfCancelled(threadId, runId)) return { runId }; const entry = claimPreparingRun(threadId, runId, { kind: "claude", + // Use the emitted effort after model/version gates. Ultracode can + // override the picker's stored effort; the same argv goes to spawn. + ...(entryDef.id === "claude" ? { + runConfiguration: { + provider: entryDef.id, + model: thread.model || null, + reasoningEffort: args.includes("--effort") + ? args[args.indexOf("--effort") + 1] : null, + }, + } : {}), startingId, workingId, claudeState, diff --git a/electron/runner.js b/electron/runner.js index b70663fe..515fd2de 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -49,6 +49,8 @@ const { isBinAvailable, listProviders, codexModelAcceptsImages, + turnModelFor, + honouredEfforts, } = require("./providers.js"); const orchcommands = require("./orchcommands.js"); const cliCommands = require("./cliCommands.js"); @@ -1477,12 +1479,18 @@ function createRunner(opts) { createdAt: Date.now(), }; if (runId) msg.runId = runId; + // Keep attribution on output too: a paged tail can omit its start event. + const live = runId && active.get(threadId); + if (role === "assistant" && live && live.runId === runId && live.runConfiguration) { + msg.runConfiguration = { ...live.runConfiguration }; + } if (tool) msg.tool = tool; if (attachments && attachments.length) msg.attachments = attachments; if (extra && extra.thinking) msg.thinking = true; if (extra && extra.fromNotice === true) msg.fromNotice = true; if (extra && extra.steer === true) msg.steer = true; if (extra && extra.pdfText === true) msg.pdfText = true; + if (extra && extra.runConfiguration) msg.runConfiguration = extra.runConfiguration; if (extra && extra.fromThread && extra.fromThread.id) { msg.fromThread = { id: String(extra.fromThread.id), @@ -1552,6 +1560,15 @@ function createRunner(opts) { } function markRunFailed(threadId, errText, runId, extraPatch) { + // A refused launch/auth/transport can fail asynchronously after dispatch + // returned a handle. With no agent output there is no conversation portion + // to attribute; keep its error, but discard the provisional model divider. + const messages = store.getMessages(threadId); + if (!messages.some((m) => m.runId === runId && + (m.role === "assistant" || m.role === "tool" || m.thinking))) { + const kept = messages.filter((m) => !(m.runId === runId && m.role === "event" && m.runConfiguration)); + if (kept.length !== messages.length) store.setMessages(threadId, kept); + } const overflow = classifyContextOverflow(errText); const writerLock = overflow ? null : classifyWriterLock(errText); const upgrade = overflow || writerLock ? null : classifyCliUpgrade(errText); @@ -1788,9 +1805,23 @@ function createRunner(opts) { const existing = active.get(threadId); if (!existing || existing.runId !== runId) return null; Object.assign(existing, fields); + // Adapters can refine the provisional picker snapshot from their resolved + // launch arguments (for example version-gated Claude Ultracode effort). + if (fields.runConfiguration) { + const boundary = store.getMessages(threadId).find((m) => + m.runId === runId && m.role === "event" && m.runConfiguration); + if (boundary) store.updateMessage(threadId, boundary.id, { + runConfiguration: fields.runConfiguration, + text: runConfigurationText(fields.runConfiguration), + }); + } return existing; } + function runConfigurationText(configuration) { + return `${configuration.provider} · ${configuration.model || "default model"} · ${configuration.reasoningEffort ? `${configuration.reasoningEffort} effort` : "default effort"}`; + } + function stampPreparingSteps(threadId, runId, steps) { const pending = active.get(threadId); if (pending && pending.runId === runId) Object.assign(pending, steps); @@ -2549,57 +2580,85 @@ function createRunner(opts) { const name = workflowNameFromThreadId(threadId); - if (provider === "simulate") { - return await startSimulatedRun(threadId, dispatchPrompt, runId, name); - } - if (provider === "generic") { - return await startGenericRun(threadId, dispatchPrompt, runId, name); - } + // Snapshot what the adapter is about to dispatch, including plan-mode + // overrides. Picker changes during a live turn only affect its next run. + const providerEntry = getProvider(provider); + const selected = turnModelFor(providerEntry, store.getThread(threadId)); + const configuration = { + provider, + model: selected.model, + reasoningEffort: honouredEfforts(providerEntry, selected.model).includes(selected.reasoningEffort) + ? selected.reasoningEffort : null, + }; + pendingEntry.runConfiguration = configuration; + const launch = async () => { + if (provider === "simulate") { + return await startSimulatedRun(threadId, dispatchPrompt, runId, name); + } + if (provider === "generic") { + return await startGenericRun(threadId, dispatchPrompt, runId, name); + } - const entryDef = getProvider(provider) || getProvider("claude"); - if (entryDef.kind === "claude-stream") { - return await startClaudeRun(threadId, dispatchPrompt, runId, entryDef); - } - if (entryDef.kind === "codex-json") { - return await startCodexRun( - threadId, - dispatchPrompt, - runId, - entryDef, - nativeImages, - ); - } - if (entryDef.kind === "kimi-stream") { - return await startKimiRun(threadId, dispatchPrompt, runId, entryDef); - } - if (entryDef.kind === "opencode-json") { - return await startOpencodeRun( + const entryDef = getProvider(provider) || getProvider("claude"); + if (entryDef.kind === "claude-stream") { + return await startClaudeRun(threadId, dispatchPrompt, runId, entryDef); + } + if (entryDef.kind === "codex-json") { + return await startCodexRun( + threadId, + dispatchPrompt, + runId, + entryDef, + nativeImages, + ); + } + if (entryDef.kind === "kimi-stream") { + return await startKimiRun(threadId, dispatchPrompt, runId, entryDef); + } + if (entryDef.kind === "opencode-json") { + return await startOpencodeRun( + threadId, + dispatchPrompt, + runId, + entryDef, + nativeFiles, + ); + } + if (entryDef.kind === "cursor-stream") { + return await startCursorRun(threadId, dispatchPrompt, runId, entryDef); + } + if (entryDef.kind === "muse-json") { + return await startMuseRun(threadId, dispatchPrompt, runId, entryDef); + } + return await startClaudeRun( threadId, dispatchPrompt, runId, - entryDef, - nativeFiles, + getProvider("claude"), ); + }; + // A separate boundary also covers queued/replayed starts with no new + // user row. Stage it immediately before dispatch and roll it back if + // launch is refused, throws, or Stop cancels preparation. + const boundaryText = runConfigurationText(configuration); + const boundaryId = appendMessage(threadId, "event", boundaryText, runId, + null, null, { runConfiguration: configuration }); + let accepted = false; + try { + const result = await launch(); + accepted = pendingEntry.kind !== "preparing" && !pendingEntry.stopping; + return result; + } finally { + if (!accepted) { + store.setMessages(threadId, store.getMessages(threadId).filter((m) => m.id !== boundaryId)); + } + store.save(); + pushDetail(threadId); } - if (entryDef.kind === "cursor-stream") { - return await startCursorRun(threadId, dispatchPrompt, runId, entryDef); - } - if (entryDef.kind === "muse-json") { - return await startMuseRun(threadId, dispatchPrompt, runId, entryDef); - } - return await startClaudeRun( - threadId, - dispatchPrompt, - runId, - getProvider("claude"), - ); } catch (err) { - // A throw before a provider claimed the slot would leak it and the - // thread would read "already active" forever (#1430 R5). - if ( - active.get(threadId) === pendingEntry && - pendingEntry.kind === "preparing" - ) { + // A provider can throw after claiming preparation but before returning + // a handle. Release that same entry too, so Retry can start again. + if (active.get(threadId) === pendingEntry) { clearRun(threadId); store.updateThread( threadId, diff --git a/electron/services-threads.js b/electron/services-threads.js index e1bf5bf8..78671794 100644 --- a/electron/services-threads.js +++ b/electron/services-threads.js @@ -13,7 +13,6 @@ const { catalogCliProbeStarted, honouredPermissionModes, snapPermissionMode, - planActSwitchNote, } = require("./providers.js"); const providerAuth = require("./providerAuth.js"); const { @@ -489,17 +488,8 @@ function setPermissionMode(store, input) { if (!thread.pendingQuestion) patch.awaitingInput = false; } const updated = store.updateThread(threadId, patch); - // Leaving plan mode hands the next turn to the act model (#1716). - const switchNote = - mode !== "plan" ? planActSwitchNote(entry, thread) : null; - if (switchNote) { - store.appendMessage(threadId, { - id: randomUUID(), - role: "event", - text: switchNote, - createdAt: Date.now(), - }); - } + // Model/effort history is written by the runner when the next turn + // actually dispatches, including a queued or rejected start (#1927). store.save(); const row = updated || { ...thread, ...patch }; return decorateThread(store, row); diff --git a/electron/test/claude.test.js b/electron/test/claude.test.js index 5944d574..20ff164d 100644 --- a/electron/test/claude.test.js +++ b/electron/test/claude.test.js @@ -2516,7 +2516,9 @@ describe("runner claude provider", () => { "the real turn must land", ); assert.deepEqual( - msgs.filter((m) => m.role === "event").map((m) => m.text), + msgs + .filter((m) => m.role === "event" && !m.runConfiguration) + .map((m) => m.text), [], "no fabricated failure while the CLI was still working", ); diff --git a/electron/test/plan-model.test.js b/electron/test/plan-model.test.js index 83a0fa9f..53e5e3ed 100644 --- a/electron/test/plan-model.test.js +++ b/electron/test/plan-model.test.js @@ -58,6 +58,10 @@ function writeFakeClaude(dir) { "use strict"; const fs = require("fs"); const argv = process.argv.slice(2); +if (argv.includes("--version")) { + console.log((process.env.FAKE_VERSION || "2.1.283") + " (Claude Code)"); + process.exit(0); +} fs.appendFileSync(process.env.FAKE_ARGS, JSON.stringify(argv) + "\\n"); const model = argv[argv.indexOf("--model") + 1]; const plan = argv[argv.indexOf("--permission-mode") + 1] === "plan"; @@ -205,13 +209,14 @@ describe("plan model on threads (#1716)", () => { let runner; let project; const saved = {}; - const ENV = ["CODER_SIMULATE", "CODER_AGENT_CMD", "CODER_CLAUDE_BIN", "FAKE_ARGS", "FAKE_STDIN", "FAKE_ASK_PLAN"]; + const ENV = ["CODER_SIMULATE", "CODER_AGENT_CMD", "CODER_CLAUDE_BIN", "FAKE_ARGS", "FAKE_STDIN", "FAKE_ASK_PLAN", "FAKE_VERSION"]; beforeEach(async () => { for (const k of ENV) saved[k] = process.env[k]; delete process.env.CODER_SIMULATE; delete process.env.CODER_AGENT_CMD; delete process.env.FAKE_ASK_PLAN; + delete process.env.FAKE_VERSION; tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-plan-model-")); process.env.CODER_CLAUDE_BIN = writeFakeClaude(tmpDir); process.env.FAKE_ARGS = path.join(tmpDir, "args"); @@ -300,7 +305,8 @@ describe("plan model on threads (#1716)", () => { assert.equal(store.getUsage(id).model, PLAN); services.setPermissionMode(store, { threadId: id, mode: "default" }); - assert.ok(events(id).includes("Switched to Sonnet 5.5 for implementation")); + assert.ok(!events(id).includes("Switched to Sonnet 5.5 for implementation"), + "a mode picker change has not dispatched the implementation model yet"); await runner.startRun({ threadId: id, prompt: "go" }); await waitFor(() => store.getThread(id).status === "done" && store.getUsage(id)?.turns === 2); @@ -310,9 +316,55 @@ describe("plan model on threads (#1716)", () => { assert.equal(flag(second, "--resume"), "sess-pm", "same session, no fork"); assert.equal(flag(second, "--effort"), null, "act effort is the thread's (none)"); assert.equal(store.getUsage(id).model, ACT); + assert.deepEqual(store.getMessages(id).filter((m) => m.role === "event" && m.runConfiguration) + .map((m) => m.runConfiguration), [ + { provider: "claude", model: PLAN, reasoningEffort: "high" }, + { provider: "claude", model: ACT, reasoningEffort: null }, + ]); assert.equal(store.getThreads().length, 1, "no new thread"); }); + it("records the launched Ultracode effort and its change back to ordinary effort", async () => { + await startRunner(); + const t = services.createThread(store, { projectId: project.id, title: "ultracode", provider: "claude" }); + store.updateThread(t.id, { model: PLAN, reasoningEffort: "low" }); + services.setUltracode(store, { threadId: t.id, ultracode: true }); + await runner.startRun({ threadId: t.id, prompt: "first" }); + await waitFor(() => store.getThread(t.id).status === "done"); + assert.equal(flag(spawns()[0], "--model"), PLAN); + assert.equal(flag(spawns()[0], "--effort"), "xhigh"); + assert.equal(store.getThread(t.id).reasoningEffort, "low", "ordinary picker preference remains independent"); + + services.setUltracode(store, { threadId: t.id, ultracode: false }); + await runner.startRun({ threadId: t.id, prompt: "second" }); + await waitFor(() => store.getThread(t.id).status === "done" && store.getUsage(t.id)?.turns === 2); + assert.equal(flag(spawns()[1], "--model"), PLAN); + assert.equal(flag(spawns()[1], "--effort"), "low"); + const configurations = store.getMessages(t.id).filter((m) => m.role === "event" && m.runConfiguration); + assert.deepEqual(configurations.map((m) => m.runConfiguration), [ + { provider: "claude", model: PLAN, reasoningEffort: "xhigh" }, + { provider: "claude", model: PLAN, reasoningEffort: "low" }, + ]); + assert.match(configurations[0].text, /xhigh effort/); + assert.match(configurations[1].text, /low effort/); + assert.deepEqual(store.getMessages(t.id).filter((m) => m.role === "assistant") + .map((m) => m.runConfiguration.reasoningEffort), ["xhigh", "low"]); + store.saveNow(); + assert.deepEqual(new Store(path.join(tmpDir, "store.json")).getMessages(t.id) + .filter((m) => m.role === "event" && m.runConfiguration), configurations); + }); + + it("records no model boundary when the installed Claude version cannot launch Ultracode", async () => { + process.env.FAKE_VERSION = "2.1.218"; + await startRunner(); + const t = services.createThread(store, { projectId: project.id, title: "old CLI", provider: "claude" }); + store.updateThread(t.id, { model: PLAN, reasoningEffort: "low" }); + services.setUltracode(store, { threadId: t.id, ultracode: true }); + await assert.rejects(runner.startRun({ threadId: t.id, prompt: "work" }), /2\.1\.219 or newer/); + assert.equal(store.getMessages(t.id).filter((m) => m.runConfiguration).length, 0); + assert.equal(fs.existsSync(process.env.FAKE_ARGS), false, "version check never launches an agent"); + }); + it("switches the live process with set_model on plan approval and bills each model its share", async () => { process.env.FAKE_ASK_PLAN = "1"; const rows = []; @@ -346,6 +398,11 @@ describe("plan model on threads (#1716)", () => { ); assert.equal(store.getUsage(id).model, ACT); assert.equal(store.getThread(id).permissionMode, "default"); + assert.deepEqual(store.getMessages(id).filter((m) => m.role === "event" && m.runConfiguration) + .map((m) => m.runConfiguration), [ + { provider: "claude", model: PLAN, reasoningEffort: "high" }, + { provider: "claude", model: ACT, reasoningEffort: "high" }, + ], "a live set_model boundary preserves the effort that the process is actually using"); const ev = events(id); assert.ok(ev.indexOf("Switched to Sonnet 5.5 for implementation") > ev.indexOf("Plan approved")); }); diff --git a/electron/test/run-configuration.test.js b/electron/test/run-configuration.test.js new file mode 100644 index 00000000..6bcfd55a --- /dev/null +++ b/electron/test/run-configuration.test.js @@ -0,0 +1,146 @@ +"use strict"; +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 { createRunner } = require("../runner.js"); +const { writeFakeBin } = require("./support/fakeBin.js"); +const { rmTree } = require("./support/rmTree.js"); + +describe("effective transcript configuration (#1927)", () => { + let tmp, store, runner, project, calls, failLaunch, saved; + beforeEach(async () => { + saved = { ...process.env }; + for (const key of Object.keys(process.env)) if (key.startsWith("CODER_")) delete process.env[key]; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "solenta-run-configuration-")); + process.env.CODER_GROK_MCP_DISABLE = "1"; + process.env.CODER_CURSOR_MCP_DISABLE = "1"; + process.env.CODER_GUARDRAILS_PATH = path.join(tmp, "guardrails"); + process.env.CODER_CODEX_BIN = writeFakeBin(path.join(tmp, "codex"), "process.exit(0)"); + process.env.CODER_GROK_BIN = "missing-grok-for-test"; + calls = []; + failLaunch = false; + store = new Store(path.join(tmp, "store.json")); + const repo = path.join(tmp, "repo"); + fs.mkdirSync(repo); + execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" }); + project = await services.addProject(store, repo); + const core = await import(pathToFileURL(path.join(__dirname, "../../core/dist/index.js")).href); + runner = createRunner({ store, core, pushFn() {}, userDataPath: tmp, + runCodexFn(options) { + if (failLaunch) throw new Error("test launch refused"); + calls.push(options); + return { kill() {}, send() { return true; } }; + }, + }); + }); + afterEach(async () => { + runner?.stopAll(); + await rmTree(tmp); + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key]; + Object.assign(process.env, saved); + }); + const makeThread = () => services.createThread(store, { projectId: project.id, provider: "codex", title: "history" }).id; + const boundaries = (id) => store.getMessages(id).filter((m) => m.runConfiguration); + function select(id, model = "gpt-6.1-sol", effort = "high") { + services.setProvider(store, { threadId: id, model }); + services.setReasoningEffort(store, { threadId: id, effort }); + } + const start = (id, prompt = "work") => runner.startRun({ threadId: id, prompt }); + + it("records accepted settings, keeps live steering on that configuration, and switches at restart", async () => { + const id = makeThread(); + select(id); + const first = await start(id); + assert.deepEqual(boundaries(id).map((m) => m.runConfiguration), [ + { provider: "codex", model: "gpt-6.1-sol", reasoningEffort: "high" }, + ]); + select(id, "gpt-6-astra", "low"); + await runner.steerRun({ threadId: id, prompt: "also check this" }); + assert.equal(boundaries(id).length, 1, "picker and steering do not start a model change"); + assert.equal(store.getMessages(id).find((m) => m.steer).runId, first.runId); + await runner.stopRun({ threadId: id }); + await start(id, "restart"); + assert.deepEqual(boundaries(id)[1].runConfiguration, + { provider: "codex", model: "gpt-6-astra", reasoningEffort: "low" }); + assert.equal(calls[0].model, "gpt-6.1-sol"); + assert.equal(calls[1].model, "gpt-6-astra"); + store.saveNow(); + const loaded = new Store(path.join(tmp, "store.json")); + assert.deepEqual(loaded.getMessages(id).filter((m) => m.runConfiguration), boundaries(id)); + }); + + it("keeps a queued follow-up on its own dispatch boundary", async () => { + const id = makeThread(); + select(id); + const first = await start(id); + services.setQueued(store, { threadId: id, prompt: "next task" }); + select(id, "gpt-6-astra", "low"); + assert.equal(boundaries(id).length, 1); + await runner.stopRun({ threadId: id }); + await runner.sendQueued({ threadId: id }); + const history = boundaries(id).filter((m) => m.role === "event"); + assert.equal(history.length, 2); + assert.notEqual(history[1].runId, first.runId); + assert.deepEqual(history[1].runConfiguration, + { provider: "codex", model: "gpt-6-astra", reasoningEffort: "low" }); + assert.equal(store.getMessages(id).filter((m) => m.role === "user" && m.text === "next task").length, 1); + }); + + it("snapshots a waiting run when it is admitted, without duplicating its prompt", async () => { + store.setSettings({ maxConcurrentRuns: 1 }); + const busy = makeThread(), waiting = makeThread(); + select(waiting, "gpt-6.1-sol", "low"); + await start(busy); + assert.equal((await start(waiting)).queued, true); + assert.equal(boundaries(waiting).length, 0); + select(waiting, "gpt-6-astra", "high"); + await runner.stopRun({ threadId: busy }); + for (let i = 0; i < 100 && !boundaries(waiting).length; i++) await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(boundaries(waiting).map((m) => m.runConfiguration), [ + { provider: "codex", model: "gpt-6-astra", reasoningEffort: "high" }, + ]); + assert.equal(store.getMessages(waiting).filter((m) => m.role === "user").length, 1); + }); + + it("discards an asynchronous failed-start marker but preserves attribution once the agent produced output", async () => { + const id = makeThread(); + select(id); + await start(id); + calls[0].onError(new Error("transport refused before starting")); + assert.equal(boundaries(id).length, 0); + await start(id, "retry"); + calls[1].onEvent({ type: "item.completed", item: { id: "answer", type: "agent_message", text: "started working" } }); + calls[1].onError(new Error("transport failed later")); + assert.equal(boundaries(id).filter((m) => m.role === "event").length, 1); + assert.deepEqual(store.getMessages(id).find((m) => m.role === "assistant").runConfiguration, + { provider: "codex", model: "gpt-6.1-sol", reasoningEffort: "high" }); + const tail = services.getThreadDetail(store, id, null, { limit: 2 }); + assert.ok(tail.messages.some((m) => m.runConfiguration), "paged history retains its output attribution"); + }); + + it("records plan overrides and no false boundary on rejected launches or missing binaries", async () => { + const id = makeThread(); + select(id, "gpt-6.1-sol", "low"); + services.setPlanModel(store, { threadId: id, model: "gpt-6-astra", effort: "high" }); + services.setPermissionMode(store, { threadId: id, mode: "plan" }); + failLaunch = true; + await assert.rejects(start(id), /test launch refused/); + assert.equal(boundaries(id).length, 0); + assert.equal(runner.isRunning(id), false, "a failed launch releases its slot for Retry"); + failLaunch = false; + await start(id); + assert.deepEqual(boundaries(id)[0].runConfiguration, + { provider: "codex", model: "gpt-6-astra", reasoningEffort: "high" }); + await runner.stopRun({ threadId: id }); + services.setProvider(store, { threadId: id, provider: "grok" }); + await assert.rejects(start(id), /not found|not installed|missing-grok/i); + assert.equal(boundaries(id).length, 1); + assert.equal(boundaries(id)[0].runConfiguration.provider, "codex", "handoff cannot rewrite prior attribution"); + }); +}); diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx index f800b6de..4ef62ce7 100644 --- a/src/components/ThreadView.tsx +++ b/src/components/ThreadView.tsx @@ -99,6 +99,10 @@ import { threadContextWindow, } from "../contextRing"; import { buildTimeline, type TimelineEntry } from "../timeline"; +import { + transcriptConfigurations, + runConfigurationLabel, +} from "../runConfiguration"; import { applyPrStep, initialPrSteps, @@ -1425,6 +1429,11 @@ export const ThreadView = memo(function ThreadView({ ); }, [detail]); + const configurations = useMemo( + () => transcriptConfigurations(detail?.messages ?? []), + [detail?.messages], + ); + const timeline = useMemo(() => { if (!detail) return []; return buildTimeline( @@ -5129,6 +5138,12 @@ export const ThreadView = memo(function ThreadView({ entry.message.role === "event" && retryEventId != null && entry.message.id === retryEventId; + const configuration = configurations.byMessageId.get(entry.message.id); + const modelBoundary = configurations.boundaryByMessageId.get(entry.message.id); + // A queued/replayed run can start at this event. Even when its + // divider deduplicates, it still needs the shared run-header path. + const isConfigurationEvent = + entry.message.role === "event" && Boolean(entry.message.runConfiguration); const bar = barByMessageId.get(entry.message.id); const runHeader = headerByMessageId.get(entry.message.id); const runId = entry.message.runId; @@ -5140,6 +5155,16 @@ export const ThreadView = memo(function ThreadView({ if (focusHidden && !focusTurn) return null; return ( + {modelBoundary && ( +
+ {runConfigurationLabel(modelBoundary)} +
+ )} {runHeader && ( )} - {!runCollapsed && !focusHidden && ( + {!runCollapsed && !focusHidden && !isConfigurationEvent && ( <>
(); + for (const message of messages) { + if (message.runId && message.runConfiguration && !initialByRun.has(message.runId)) { + initialByRun.set(message.runId, message.runConfiguration); + } + } + const currentByRun = new Map(initialByRun); + const byMessageId = new Map(); + const boundaryByMessageId = new Map(); + let previous: RunConfiguration | undefined; + for (const message of messages) { + const configuration = message.runConfiguration; + if (configuration) { + if (!sameConfiguration(previous, configuration)) boundaryByMessageId.set(message.id, configuration); + previous = configuration; + if (message.runId) currentByRun.set(message.runId, configuration); + } + const current = message.runId ? currentByRun.get(message.runId) : undefined; + if (current) byMessageId.set(message.id, current); + } + return { byMessageId, boundaryByMessageId }; +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 2a01755e..250f4476 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1748,7 +1748,18 @@ export interface AttachmentInfo { /** Per-thread inbound policy for cross-thread messages (issue #551). */ export type CrossThreadInbound = "accept" | "queue-only" | "refuse"; +/** Immutable dispatch settings for a run, or an explicit in-session model switch. + * Null model/effort means the provider default, whose exact value is unknown. + */ +export interface RunConfiguration { + provider: string; + model: string | null; + reasoningEffort: string | null; +} + export interface ChatMessage { + /** Dispatch/switch boundary on events; immutable attribution on assistant output. */ + runConfiguration?: RunConfiguration; /** Persistent divider marking a new provider conversation. */ sessionBoundary?: boolean; id: string; diff --git a/test/runConfiguration.test.ts b/test/runConfiguration.test.ts new file mode 100644 index 00000000..10a2ff83 --- /dev/null +++ b/test/runConfiguration.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ChatMessage, RunConfiguration } from "../src/shared/ipc"; +import { transcriptConfigurations, runConfigurationLabel } from "../src/runConfiguration"; + +const first: RunConfiguration = { provider: "codex", model: "gpt-6.1-sol", reasoningEffort: "high" }; +const low = { ...first, reasoningEffort: "low" }; +const handoff = { provider: "claude", model: "claude-opus-5-5", reasoningEffort: null }; +function row(id: string, runId: string | null, configuration?: RunConfiguration, role: ChatMessage["role"] = "event"): ChatMessage { + return { id, role, runId, createdAt: Number(id.replace(/\D/g, "")) || 1, text: id, ...(configuration ? { runConfiguration: configuration } : {}) }; +} + +describe("transcript configuration history", () => { + it("deduplicates identical turns, records effort-only changes and preserves a provider handoff after reload", () => { + const messages = [row("u1", "r1", undefined, "user"), row("b1", "r1", first), row("a1", "r1", undefined, "assistant"), + row("b2", "r2", first), row("b3", "r3", low), row("a3", "r3", undefined, "assistant"), + row("b4", "r4", handoff), row("a4", "r4", undefined, "assistant")]; + const result = transcriptConfigurations(JSON.parse(JSON.stringify(messages))); + assert.deepEqual([...result.boundaryByMessageId.keys()], ["b1", "b3", "b4"]); + assert.deepEqual(result.byMessageId.get("u1"), first); + assert.deepEqual(result.byMessageId.get("a1"), first); + assert.deepEqual(result.byMessageId.get("a3"), low); + assert.deepEqual(result.byMessageId.get("a4"), handoff); + }); + + it("leaves legacy and unstarted queued rows unattributed and never carries the current picker backward", () => { + const result = transcriptConfigurations([row("old", "legacy", undefined, "assistant"), row("queue", null, undefined, "user"), row("b1", "r1", first)]); + assert.equal(result.byMessageId.has("old"), false); + assert.equal(result.byMessageId.has("queue"), false); + assert.equal(result.boundaryByMessageId.size, 1); + }); + + it("in-session model changes affect only later portions of that run", () => { + const next = { ...first, model: "gpt-6-astra" }; + const result = transcriptConfigurations([row("b1", "r1", first), row("a1", "r1", undefined, "assistant"), row("b2", "r1", next), row("a2", "r1", undefined, "assistant")]); + assert.deepEqual(result.byMessageId.get("a1"), first); + assert.deepEqual(result.byMessageId.get("a2"), next); + assert.equal(result.boundaryByMessageId.size, 2); + }); + + it("attributes a paged tail from the snapshot on assistant output", () => { + const result = transcriptConfigurations([row("a1", "r1", first, "assistant"), row("a2", "r1", first, "assistant")]); + assert.deepEqual(result.byMessageId.get("a1"), first); + assert.deepEqual([...result.boundaryByMessageId.keys()], ["a1"]); + }); + + it("names unknown provider defaults without inventing model or effort values", () => { + assert.equal(runConfigurationLabel(handoff), "claude · opus 5.5 · default effort"); + assert.equal(runConfigurationLabel({ provider: "codex", model: null, reasoningEffort: null }), "codex · default model · default effort"); + }); +}); diff --git a/test/threadHeader.test.tsx b/test/threadHeader.test.tsx index fcca74e5..213785ee 100644 --- a/test/threadHeader.test.tsx +++ b/test/threadHeader.test.tsx @@ -93,6 +93,7 @@ function msg( text: over.text, createdAt: over.createdAt ?? 1, runId: over.runId ?? null, + runConfiguration: over.runConfiguration, tool: over.tool, }; } @@ -320,6 +321,64 @@ describe("time spent in the message footer", () => { }); describe("run headers", () => { + const configuration = { + provider: "codex", + model: "gpt-6.1-sol", + reasoningEffort: "high", + }; + const snapshot = (id: string, runId: string, createdAt: number) => msg({ + id, runId, createdAt, role: "event", text: "Model and effort", + runConfiguration: configuration, + }); + + it("keeps configuration-anchored run headers when adjacent snapshots deduplicate", async () => { + const m = await mount(view({ detail: detail({ messages: [ + snapshot("config1", "r1", 1_000), + msg({ id: "a1", role: "assistant", text: "FIRST_REPLY", runId: "r1", createdAt: 2_000 }), + snapshot("config2", "r2", 3_000), + snapshot("config2-repeat", "r2", 3_001), + msg({ id: "a2", role: "assistant", text: "SECOND_REPLY", runId: "r2", createdAt: 4_000 }), + ] }) })); + await m.flush(); + assert.equal(m.queryAll('[aria-label="Model and effort"]').length, 1); + assert.equal(m.queryAll("[data-run-header]").length, 2); + assert.equal(m.queryAll("[data-run-header='r2']").length, 1); + assert.ok(m.html().indexOf('data-run-header="r1"') < m.html().indexOf("FIRST_REPLY")); + await m.click(m.query("[data-run-header='r2']")); + assert.ok(!m.text().includes("SECOND_REPLY")); + assert.ok(m.text().includes("FIRST_REPLY")); + assert.equal(m.queryAll("[data-run-header='r2']").length, 1); + await m.click(m.query("[data-run-header='r2']")); + assert.ok(m.text().includes("SECOND_REPLY")); + }); + + it("does not add another header when the configuration follows the user row", async () => { + const m = await mount(view({ detail: detail({ messages: [ + msg({ id: "u1", role: "user", text: "prompt", runId: "r1", createdAt: 1_000 }), + snapshot("config1", "r1", 1_001), + msg({ id: "a1", role: "assistant", text: "reply", runId: "r1", createdAt: 2_000 }), + ] }) })); + await m.flush(); + assert.equal(m.queryAll("[data-run-header='r1']").length, 1); + assert.equal(m.queryAll('[aria-label="Model and effort"]').length, 1); + }); + + it("preserves a collapsed run header when an older configuration page is prepended", async () => { + const answer = msg({ id: "a1", role: "assistant", text: "PAGED_REPLY", runId: "r1", createdAt: 2_000 }); + const m = await mount(view({ detail: { + ...detail({ messages: [answer] }), hasOlderMessages: true, + } })); + await m.flush(); + await m.click(m.query("[data-run-header='r1']")); + await m.rerender(view({ detail: detail({ messages: [ + snapshot("config1", "r1", 1_000), answer, + ] }) })); + assert.equal(m.queryAll("[data-run-header='r1']").length, 1); + assert.ok(!m.text().includes("PAGED_REPLY")); + await m.click(m.query("[data-run-header='r1']")); + assert.ok(m.text().includes("PAGED_REPLY")); + }); + it("renders a Worked for header above each completed run", async () => { const m = await mount(view({ detail: twoRuns })); await m.flush(); diff --git a/test/threadView.test.tsx b/test/threadView.test.tsx index 4115d480..0d4330a5 100644 --- a/test/threadView.test.tsx +++ b/test/threadView.test.tsx @@ -569,6 +569,32 @@ describe("ThreadView sandbox badge", () => { }); }); +describe("ThreadView model and effort history (#1927)", () => { + it("renders change dividers and historical footers after provider and effort changes", () => { + const initial = { provider: "codex", model: "gpt-6.1-sol", reasoningEffort: "high" }; + const next = { provider: "claude", model: "claude-opus-5-5", reasoningEffort: "low" }; + const messages: ChatMessage[] = [ + { id: "boundary1", role: "event", text: "Model and effort", runId: "r1", createdAt: 1, runConfiguration: initial }, + msg({ id: "answer1", role: "assistant", text: "old answer", runId: "r1", createdAt: 2 }), + { id: "boundary2", role: "event", text: "Model and effort", runId: "r2", createdAt: 3, runConfiguration: initial }, + { id: "boundary3", role: "event", text: "Model and effort", runId: "r3", createdAt: 4, runConfiguration: next }, + msg({ id: "answer3", role: "assistant", text: "new answer", runId: "r3", createdAt: 5 }), + msg({ id: "legacy", role: "assistant", text: "legacy answer", runId: "old", createdAt: 6 }), + ]; + const html = renderToStaticMarkup(view({ detail: detail({ messages, + thread: thread({ provider: "grok", model: "grok-4.7", reasoningEffort: "medium" }), + }) })); + assert.equal((html.match(/aria-label="Model and effort"/g) ?? []).length, 2); + assert.match(html, /codex · gpt-6.1-sol · high effort/); + assert.match(html, /claude · opus 5.5 · low effort/); + const root = document.createElement("div"); + root.innerHTML = html; + assert.match(root.querySelector('[data-msg="answer1"] footer')!.textContent!, /codex · gpt-6.1-sol · high/); + assert.match(root.querySelector('[data-msg="answer3"] footer')!.textContent!, /claude · opus 5.5 · low/); + assert.doesNotMatch(root.querySelector('[data-msg="legacy"] footer')!.textContent!, /grok|medium|gpt|opus/); + }); +}); + describe("ThreadView message roles", () => { it("renders user and assistant messages with distinct structure", () => { const html = render({