fix(opencode): clear unsupported thinking when changing models (#5587)

Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.
This commit is contained in:
Mohamed Boudra
2026-09-28 22:32:35 +01:00
committed by GitHub
parent 4340607093
commit 940dbfd242
7 changed files with 343 additions and 14 deletions
@@ -0,0 +1,78 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
import { submitMessage } from "../support/helpers/composer";
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
test.use({
e2eDaemonConfig: {
version: 1,
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
},
});
async function selectMedium(page: Page) {
await page
.getByRole("button", { name: /^Select thinking option/ })
.filter({ visible: true })
.click();
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
await expect(
page
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
.filter({ visible: true }),
).toBeVisible();
}
async function selectLongcat(page: Page) {
await page
.getByRole("button", { name: /^Select model \(/ })
.filter({ visible: true })
.click();
await page
.getByText("LongCat 2.5 Preview Free", { exact: true })
.filter({ visible: true })
.click();
}
async function askForReply(handle: AgentHandle) {
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
return handle.client.waitForFinish(handle.agentId, 120_000);
}
test("switching an existing OpenCode session clears unsupported thinking", async ({
page,
}, testInfo) => {
test.setTimeout(240_000);
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
cwd,
provider: "opencode",
mode: "full-access",
providerConfig: { model: "opencode/space-bunny-free" },
});
await selectMedium(page);
expect((await askForReply(handle)).status).toBe("idle");
await selectLongcat(page);
await expect(
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
).toHaveCount(0);
const finish = await askForReply(handle);
const screenshot = testInfo.outputPath("model-switch.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("After switching to Longcat", {
path: screenshot,
contentType: "image/png",
});
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeFalsy();
await expect(
page.getByTestId("assistant-message").filter({ visible: true }).last(),
).toContainText("OK");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -4851,6 +4851,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
});
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
class ModelSession extends TestAgentSession {
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
async setModel(model: string | null) {
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
this.pushEvent({
type: "thinking_option_changed",
provider: "codex",
thinkingOptionId: null,
});
}
describePersistence() {
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
}
}
class ModelClient extends TestAgentClient {
async createSession(config: AgentSessionConfig) {
return new ModelSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new ModelClient() },
registry: storage,
logger,
});
try {
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(agent.id, "target");
await manager.flush();
const stored = await storage.get(agent.id);
expect(stored?.config?.model).toBe("target");
expect(stored?.config?.thinkingOptionId).toBeUndefined();
expect(stored?.persistence?.metadata?.model).toBe("target");
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
class ConfigMutationSession extends TestAgentSession {
@@ -1943,6 +1943,7 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -333,6 +333,76 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
model: TEST_MODEL,
});
test.each([
{ variants: { medium: {}, custom: {} }, selected: "medium", expected: "medium" },
{ variants: { custom: {} }, selected: "custom", expected: "custom" },
{ variants: { high: {} }, selected: "medium", expected: undefined },
{ variants: {}, selected: "medium", expected: undefined },
])(
"model switches resolve the retained variant: $selected / $variants",
async ({ variants, selected, expected }) => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const upstream = new TestOpenCodeClient();
upstream.providerListResponse = {
data: {
connected: ["test"],
all: [
{
id: "test",
name: "Test",
source: "api",
models: { target: { name: "Target", variants } },
},
],
},
};
runtime.enqueueClient(upstream);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd,
model: "test/source",
thinkingOptionId: selected,
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.setModel!("test/target");
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(expected ?? null);
expect(events).toContainEqual({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: expected ?? null,
});
upstream.sessionPromptAsyncEvents = [
{ type: "session.idle", properties: { sessionID: "session-1" } },
];
await collectTurnEvents(streamSession(session, "Use the selected model"));
expect(upstream.calls.sessionPromptAsync).toEqual([
expect.objectContaining({
model: { providerID: "test", modelID: "target" },
...(expected ? { variant: expected } : {}),
}),
]);
if (!expected) expect(upstream.calls.sessionPromptAsync[0]).not.toHaveProperty("variant");
await session.setThinkingOption!("medium");
await expect(session.setModel!("test/missing")).rejects.toThrow(
"OpenCode model unavailable",
);
upstream.providerListResponse = { error: "Catalog unavailable" };
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
expect((await session.getRuntimeInfo()).model).toBe("test/target");
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("usage reference follows the active OpenCode model and OAuth account", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -386,7 +456,13 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
expect(events).toContainEqual({
type: "model_changed",
provider: "opencode",
runtimeInfo: { provider: "opencode", sessionId, model: "openai/gpt-5", modeId: null },
runtimeInfo: {
provider: "opencode",
sessionId,
model: "openai/gpt-5",
modeId: null,
thinkingOptionId: null,
},
});
await session.close();
rmSync(cwd, { recursive: true, force: true });
@@ -3616,6 +3616,7 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
thinkingOptionId: this.config.thinkingOptionId ?? null,
};
}
@@ -3627,12 +3628,32 @@ class OpenCodeAgentSession implements AgentSession {
}
async setModel(modelId: string | null): Promise<void> {
await this.reconnectIfServerExited();
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
let variant = this.config.thinkingOptionId;
if (!normalizedModelId) {
variant = undefined;
} else if (variant) {
const model = this.parseModel(normalizedModelId);
const response = await this.client.provider.list({ directory: this.config.cwd });
if (response.error)
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
const provider = response.data?.all.find((entry) => entry.id === model?.providerID);
const target = model && provider?.models[model.modelID];
if (!target) throw new Error(`OpenCode model unavailable: ${normalizedModelId}`);
if (!Object.hasOwn(target.variants ?? {}, variant)) variant = undefined;
}
this.config.model = normalizedModelId ?? undefined;
this.config.thinkingOptionId = variant;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
this.config.model,
);
this.notifySubscribers({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: variant ?? null,
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1,4 +1,4 @@
import type { SessionMessageInfo } from "@opencode/client";
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -7,6 +7,89 @@ import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
describe("OpenCode v2 session lifecycle", () => {
test.each([
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
{ variants: ["custom"], selected: "custom", expected: "custom" },
{ variants: ["high"], selected: "medium", expected: undefined },
{ variants: [], selected: "medium", expected: undefined },
{ variants: ["medium"], selected: "default", expected: undefined },
])(
"model switches retain only supported variants: $selected / $variants",
async ({ variants, selected, expected }) => {
const harness = new V2Harness();
const target: ModelInfo = {
id: "target",
modelID: "target",
providerID: "test",
name: "Target",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: variants.map((id) => ({ id })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200000, output: 10000 },
};
harness.info.model = { providerID: "test", id: "source", variant: selected };
harness.api.model.list = async () => ({ location: harness.info.location, data: [target] });
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "test/source",
thinkingOptionId: selected,
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.setModel!("test/target");
expect(await session.getRuntimeInfo()).toMatchObject({
model: "test/target",
thinkingOptionId: expected ?? null,
});
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
expect(events).toContainEqual({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: expected ?? null,
});
await expect(session.setModel!("test/missing")).rejects.toThrow(
"OpenCode model unavailable",
);
harness.api.session.switchModel = async () => {
throw new Error("Switch failed");
};
await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed");
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
expect(harness.info.model).toEqual({
providerID: "test",
id: "target",
...(expected ? { variant: expected } : {}),
});
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
harness.api.model.default = async () => ({ location: harness.info.location, data: target });
await session.setThinkingOption!("unsupported");
await session.setModel!(null);
expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull();
harness.api.model.list = async () => {
throw new Error("Catalog unavailable");
};
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
expect((await session.getRuntimeInfo()).model).toBe("test/target");
} finally {
await session.close();
}
},
);
test("restores each same-directory agent environment on reconnect and resume", async () => {
const first = new V2Harness();
const second = new V2Harness();
@@ -276,25 +276,48 @@ export class OpenCodeV2Session implements AgentSession {
}
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model, this.config.thinkingOptionId)
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
const location = { directory: this.config.cwd };
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
if (!selected) throw new Error("OpenCode has no default model");
await this.client.session.switchModel({
sessionID: this.id,
model: {
id: selected.id,
providerID: selected.providerID,
variant: this.config.thinkingOptionId,
},
});
const catalog = await this.client.model.list({ location });
const target = catalog.data.find(
(entry) =>
entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id,
);
if (!target)
throw new Error(`OpenCode model unavailable: ${selected.providerID}/${selected.id}`);
const retainedVariant = this.config.thinkingOptionId;
const variant =
retainedVariant !== "default" && target.variants.some((entry) => entry.id === retainedVariant)
? retainedVariant
: undefined;
const nextModel = {
id: selected.id,
providerID: selected.providerID,
...(variant ? { variant } : {}),
};
await this.client.session.switchModel({ sessionID: this.id, model: nextModel });
this.info.model = nextModel;
this.config.model = model ?? undefined;
this.config.thinkingOptionId = variant;
this.emit({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: variant ?? null,
});
this.emit({
type: "model_changed",
provider: "opencode",
runtimeInfo: await this.getRuntimeInfo(),
runtimeInfo: {
provider: "opencode",
sessionId: this.id,
model: `${selected.providerID}/${selected.id}`,
modeId: this.info.agent ?? null,
thinkingOptionId: variant ?? null,
},
});
}
async setThinkingOption(variant: string | null) {
await this.reconnectIfExited();
const model = this.info.model;