diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 374f87e09b..85a303baf1 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -700,6 +700,71 @@ test("stops an OpenCode execution already active when its conversation is reopen } }); +test("a completed OpenCode model switch does not depend on another session read", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-model-")); + const harness = new V2Harness(); + harness.info.location.directory = workdir; + harness.info.model = { providerID: "provider", id: "source" }; + let observationUnavailable = false; + harness.api.model.list = async () => ({ + location: { directory: workdir }, + data: [ + { + providerID: "provider", + id: "target", + modelID: "target", + name: "Target", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + enabled: true, + variants: [], + time: { released: 1 }, + cost: [], + status: "active", + limit: { context: 200000, output: 10000 }, + }, + ], + }); + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + observationUnavailable = true; + }; + harness.api.session.get = async () => { + if (observationUnavailable) throw new Error("Transport"); + return harness.info; + }; + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime }); + const manager = new AgentManager({ clients: { opencode: client }, registry: storage, logger }); + const agent = await manager.resumeAgentFromPersistence({ + provider: "opencode", + sessionId: "session", + nativeHandle: "session", + metadata: { cwd: workdir }, + }); + try { + await manager.setAgentModel(agent.id, "provider/target"); + await manager.flush(); + expect(harness.info.model?.id).toBe("target"); + expect(manager.getAgent(agent.id)?.config.model).toBe("provider/target"); + expect(manager.getAgent(agent.id)?.runtimeInfo?.model).toBe("provider/target"); + const stored = await storage.get(agent.id); + expect(stored?.config?.model).toBe("provider/target"); + expect(stored?.runtimeInfo?.model).toBe("provider/target"); + expect(stored?.persistence?.metadata?.model).toBe("provider/target"); + observationUnavailable = false; + for await (const _event of manager.streamAgent(agent.id, "continue after switching")) { + // A successful mutation leaves a usable session. + } + expect(harness.prompts).toEqual(["continue after switching"]); + } finally { + observationUnavailable = false; + await manager.closeAgent(agent.id); + await manager.flush(); + await client.shutdown(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("uses an injected timeline store without making it a production requirement", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-")); const store = new RecordingTimelineStore(); @@ -5415,6 +5480,9 @@ test("model changes persist the resolved thinking and fresh provider handle befo thinkingOptionId: null, }); } + override async getRuntimeInfo() { + return { ...(await super.getRuntimeInfo()), ...this.selection }; + } describePersistence() { return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } }; } @@ -5497,6 +5565,63 @@ test("later explicit config mutations win over events emitted by earlier mutatio expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high"); }); +test.each([ + { requested: "unavailable", effective: "source" }, + { requested: "alias", effective: "canonical" }, + { requested: null, effective: null }, +])( + "setAgentModel stores effective selections and preserves resets for $requested", + async ({ requested, effective }) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-effective-model-")); + class SelectingSession extends TestAgentSession { + private model: string | null = "source"; + + async setModel(model: string | null) { + if (model && model !== "unavailable") this.model = model === "alias" ? "canonical" : model; + return { model: this.model }; + } + + override async getRuntimeInfo() { + return { ...(await super.getRuntimeInfo()), model: this.model }; + } + } + class SelectingClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new SelectingSession(config); + } + } + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new SelectingClient() }, + registry: storage, + logger, + }); + try { + const snapshot = await manager.createAgent( + { provider: "codex", cwd: workdir, model: "source" }, + undefined, + { workspaceId: undefined }, + ); + await manager.setAgentModel(snapshot.id, requested); + await manager.flush(); + + expect(manager.getAgent(snapshot.id)?.config.model).toBe(effective ?? undefined); + expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe(effective); + const persisted = await storage.get(snapshot.id); + expect(persisted?.config?.model).toBe(effective ?? undefined); + expect(persisted?.runtimeInfo?.model).toBe(effective); + + await manager.setAgentModel(snapshot.id, "target"); + expect(manager.getAgent(snapshot.id)?.config.model).toBe("target"); + expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe("target"); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + test("setAgentThinkingOption adopts the session's effective clamped level", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-")); class ClampingSession extends TestAgentSession { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index e976f40853..b13de1930a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2334,14 +2334,16 @@ export class AgentManager { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; - if (agent.session.setModel) { - await agent.session.setModel(normalizedModelId); - } + const selection = await agent.session.setModel?.(normalizedModelId); await this.drainSessionEvents(agentId); - agent.config.model = normalizedModelId ?? undefined; + // A reset clears the preference even when the provider retains its active model. + let effectiveModelId = normalizedModelId; + if (normalizedModelId !== null && selection) effectiveModelId = selection.model; + + agent.config.model = effectiveModelId ?? undefined; if (agent.runtimeInfo) { - agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId }; + agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId }; } this.refreshSessionPersistence(agent); this.touchUpdatedAt(agent); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7604f1db76..c67d2afe20 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -720,7 +720,9 @@ export interface AgentSession { /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; - setModel?(modelId: string | null): Promise; + /** A returned model is the effective selection committed by this mutation. + * Returning void means the requested selection was accepted. No status read is required. */ + setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; revertConversation?(input: { messageId: string }): Promise; diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 2d20056930..3170211757 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1400,9 +1400,10 @@ class PluginAgentSession implements AgentSession { })); } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { this.assertReplayComplete(); await this.bridge.configure({ model: modelId }); + return { model: this.bridge.config.model ?? null }; } async setThinkingOption(thinkingOptionId: string | null): Promise { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index ae80dc74f7..00794a136d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -520,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho setSessionConfigOption.mockClear(); - await expect(session.setModel("new-provider-model")).resolves.toBeUndefined(); + await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" }); expect(childLogger.warn).toHaveBeenCalledWith( { value: "new-provider-model" }, expect.stringContaining("is not a valid claude-acp model config option"), diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 4b67d827dd..d6eb391b9a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -2281,16 +2281,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { }; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); } if (!modelId) { this.currentModel = null; - return; + return { model: this.currentModel }; } await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + return { model: this.currentModel }; } private resolveModelSelection(modelId: string): ACPModelSelection { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 06b0c36e1f..b4bd7e27db 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2782,7 +2782,7 @@ class ClaudeAgentSession implements AgentSession { this.currentMode = normalized; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null; const activeQuery = await this.ensureQuery(); @@ -2800,6 +2800,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; // Model change affects persistence metadata, so invalidate cached handle. this.persistence = null; + return { model: this.lastOptionsModel ?? this.config.model ?? null }; } private reconcileThinkingOptionForModel(modelId: string | null): void { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c8b44c0bd8..97ded8292f 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1302,10 +1302,10 @@ export class OmpAgentSession implements AgentSession { return null; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const parsedReference = parseModelReference(modelId); if (!parsedReference) { - return; + return { model: modelToId(this.state.model) }; } if (!parsedReference.provider) { throw new Error(`OMP model id must include a provider: ${modelId}`); @@ -1314,6 +1314,7 @@ export class OmpAgentSession implements AgentSession { const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); this.config.model = `${model.provider}/${model.id}`; await this.refreshState(); + return { model: modelToId(this.state.model) }; } async setThinkingOption(thinkingOptionId: string | null): Promise { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 58981c7086..5d032d1476 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -367,6 +367,7 @@ export class OpenCodeV2Session implements AgentSession { thinkingOptionId: variant ?? null, }, }); + return { model: `${selected.providerID}/${selected.id}` }; } async setThinkingOption(variant: string | null) { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 513f9b62e9..5458495821 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1704,10 +1704,10 @@ export class PiRpcAgentSession implements AgentSession { return null; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const parsedReference = parseModelReference(modelId); if (!parsedReference) { - return; + return { model: modelToId(this.state.model) }; } if (!parsedReference.provider) { throw new Error(`Pi model id must include a provider: ${modelId}`); @@ -1716,6 +1716,7 @@ export class PiRpcAgentSession implements AgentSession { const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); await this.refreshState(); this.config.model = `${model.provider}/${model.id}`; + return { model: modelToId(this.state.model) }; } async setThinkingOption(thinkingOptionId: string | null): Promise {