Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 78cc05516a Rerun CI after the website dev server timed out at startup 2026-09-28 21:56:14 +02:00
paseo-bot[bot] 4e21643e3a Keep OpenCode chats working after switching to a model without the current variant
Switching an OpenCode v2 chat to another model carried the previous
model's variant over. OpenCode rejects a prompt whose variant the model
does not offer, so every message after the switch failed with
"Variant unavailable for <model>: medium". The variant now carries over
only when the new model offers it; otherwise it is cleared and the
cleared thinking option is reported to the agent.

Closes #5578
2026-09-28 21:36:21 +02:00
6 changed files with 102 additions and 60 deletions
@@ -210,17 +210,6 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
it("keeps a line break when a chunk ends in the next line's indent", () => {
const harness = streamHarness();
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
harness.send(assistant(" "));
const result = harness.send(assistant(" B --> C[Finish]\n```"));
expect(result.head).toMatchObject([
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
]);
});
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
@@ -112,10 +112,8 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
@@ -5066,42 +5066,6 @@ test("session config drift events update state through the stream channel", asyn
expect(streams.map((event) => event.type)).toEqual([]);
});
test("provider-side model switch survives reload and daemon restart", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-model-switch-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
class ModelSwitchClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config);
return capturedSession;
}
}
const client = new ModelSwitchClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000134",
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "gpt-5.2-codex" },
undefined,
{ workspaceId: undefined },
);
capturedSession!.pushEvent({
type: "model_changed",
provider: "codex",
runtimeInfo: { provider: "codex", sessionId: capturedSession!.id, model: "gpt-5.4" },
});
await manager.flush();
await manager.reloadAgentSession(snapshot.id);
expect(client.resumeOverrides.at(-1)?.model).toBe("gpt-5.4");
expect((await storage.get(snapshot.id))?.config?.model).toBe("gpt-5.4");
rmSync(workdir, { recursive: true, force: true });
});
test("setLabels merges and persists labels", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-"));
const storagePath = join(workdir, "agents");
@@ -4310,7 +4310,6 @@ export class AgentManager {
return undefined;
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
agent.config.model = event.runtimeInfo.model ?? agent.config.model;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
@@ -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";
@@ -535,4 +535,85 @@ describe("OpenCode v2 session lifecycle", () => {
await session.close();
}
});
test("switching to a model without the current variant sends the next prompt without it", async () => {
const harness = offeringModels({
"opencode/space-bunny-free": ["low", "medium", "high"],
"opencode/longcat-2.5-preview-free": [],
"opencode/muse-spark-free": ["minimal", "medium"],
});
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "opencode/space-bunny-free",
thinkingOptionId: "medium",
});
const thinkingChanges: Array<string | null> = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") thinkingChanges.push(event.thinkingOptionId);
});
try {
await session.setModel!("opencode/muse-spark-free");
expect(await session.getRuntimeInfo!()).toMatchObject({ thinkingOptionId: "medium" });
await session.setModel!("opencode/longcat-2.5-preview-free");
await session.run("hello");
expect(harness.prompts).toEqual(["hello"]);
expect(await session.getRuntimeInfo!()).toMatchObject({
model: "opencode/longcat-2.5-preview-free",
thinkingOptionId: null,
});
expect(thinkingChanges).toEqual([null]);
} finally {
await session.close();
}
});
});
// Behaves like an OpenCode v2 server: switching to any listed model succeeds,
// and a prompt fails when the session's variant is not one its model offers.
function offeringModels(variantsByModel: Record<string, string[]>): V2Harness {
const harness = new V2Harness();
const catalog: ModelInfo[] = Object.entries(variantsByModel).map(([ref, variants]) => {
const [providerID, id] = ref.split("/");
return {
id,
modelID: id,
providerID,
name: id,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: variants.map((variant) => ({ id: variant })),
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 1000, output: 1000 },
};
});
harness.api.model.list = async (input) => ({
location: { directory: input?.location?.directory ?? harness.info.location.directory },
data: catalog,
});
harness.api.session.create = async (input = {}) => {
harness.creates.push(input);
harness.info.model = input.model;
return harness.info;
};
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
harness.prompt = async (input) => {
const model = harness.info.model;
const offered = catalog.find(
(entry) => entry.providerID === model?.providerID && entry.id === model?.id,
);
if (model?.variant && !offered?.variants.some((variant) => variant.id === model.variant))
throw new Error(`Variant unavailable for ${model.providerID}/${model.id}: ${model.variant}`);
harness.prompts.push(input.text);
};
return harness;
}
@@ -3,7 +3,7 @@ import { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js";
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js";
import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
import type { ModelRef, SessionInfo, SessionMessageInfo } from "@opencode/client";
import { setTimeout as delay } from "node:timers/promises";
import type { Logger } from "pino";
@@ -277,24 +277,35 @@ export class OpenCodeV2Session implements AgentSession {
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model, this.config.thinkingOptionId)
? modelRef(model)
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
if (!selected) throw new Error("OpenCode has no default model");
// OpenCode rejects a prompt whose variant the model does not offer, so the
// current variant only carries over to a model that has it.
const variant = await this.offeredVariant(selected, this.config.thinkingOptionId);
await this.client.session.switchModel({
sessionID: this.id,
model: {
id: selected.id,
providerID: selected.providerID,
variant: this.config.thinkingOptionId,
},
model: { id: selected.id, providerID: selected.providerID, variant },
});
this.config.model = model ?? undefined;
if (variant !== this.config.thinkingOptionId) {
this.config.thinkingOptionId = variant;
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: null });
}
this.emit({
type: "model_changed",
provider: "opencode",
runtimeInfo: await this.getRuntimeInfo(),
});
}
private async offeredVariant(model: ModelRef, variant: string | undefined) {
if (!variant) return undefined;
const { data } = await this.client.model.list({ location: { directory: this.config.cwd } });
const offered = data.find(
(entry) => entry.providerID === model.providerID && entry.id === model.id,
);
return offered?.variants.some((entry) => entry.id === variant) ? variant : undefined;
}
async setThinkingOption(variant: string | null) {
await this.reconnectIfExited();
const model = this.info.model;