diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index fe1111ec12e..6d409c99f6b 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -91,6 +91,7 @@ import { CommandPlugin } from "./command.js" import { NativeCompactionPlugin } from "./compaction.js" import { IdentityPlugin } from "./identity.js" import { PlanPlugin } from "./plan.js" +import { ModelSwitchPlugin } from "./model-switch.js" import { ModelsDevPlugin } from "./models-dev.js" import { McpCodeModeDefaultsPlugin } from "./mcp-codemode-defaults.js" import { ProviderPlugins } from "./provider.js" @@ -228,6 +229,8 @@ const pre = [ ...ProviderPlugins, ...WebSearchPlugins, PatchTool.Plugin, + // Sees the editing tools the patch plugin left for this model. + ModelSwitchPlugin.Plugin, // Render model prompts after the patch plugin selects the available editing tools. ...OptimizePlugin.Plugins, IdentityPlugin.Plugin, diff --git a/packages/core/src/plugin/model-switch.ts b/packages/core/src/plugin/model-switch.ts new file mode 100644 index 00000000000..d19be36cc08 --- /dev/null +++ b/packages/core/src/plugin/model-switch.ts @@ -0,0 +1,69 @@ +export * as ModelSwitchPlugin from "./model-switch.js" + +import { Message } from "@opencode/ai" +import { define } from "@opencode/plugin/effect/plugin" +import type { SessionMessage } from "@opencode/schema/session-message" +import { Effect, Option, Schema } from "effect" + +const key = "opencode.model-switch" +const decodeAnnounced = Schema.decodeUnknownOption(Schema.Array(Schema.String)) + +const list = (names: ReadonlyArray) => names.map((name) => `\`${name}\``).join(" and ") + +/** + * When a request offers a different model a history whose tool calls name + * tools this request does not offer, remind the model once which of those + * tools it lacks. The reminder is persisted so later requests, and later + * switches to models with the same tools, do not repeat it. + */ +export const Plugin = define({ + id: key, + effect: Effect.fn(function* (ctx) { + yield* ctx.session.hook("context", (event) => + Effect.gen(function* () { + const offered = new Set(Object.keys(event.tools)) + + // Cheap check first: does the outgoing request call any tool it does not offer? + const outgoing = event.messages.flatMap((message) => (message.role === "assistant" ? message.content : [])) + if (!outgoing.some((part) => part.type === "tool-call" && !offered.has(part.name))) return + + // Only calls another model made count; a tool this model used and then lost is not a switch. + const history = yield* ctx.session + .context({ sessionID: event.sessionID }) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])) + const foreign = history.filter( + (message): message is SessionMessage.Assistant => + message.type === "assistant" && + (message.model.providerID !== event.model.providerID || message.model.id !== event.model.id), + ) + const calls = foreign.flatMap((message) => + message.content.flatMap((part) => (part.type === "tool" ? [part.name] : [])), + ) + const removed = [...new Set(calls)].filter((name) => !offered.has(name)) + if (removed.length === 0) return + + // Say nothing if the most recent reminder already named all of them. + const last = history.findLast((message) => message.metadata?.[key] !== undefined) + const announced = Option.getOrUndefined(decodeAnnounced(last?.metadata?.[key])) ?? [] + if (removed.every((name) => announced.includes(name))) return + + // GPT models edit through patch; everything else uses edit and write. + const added = (removed.includes("patch") ? ["edit", "write"] : ["patch"]).filter((name) => offered.has(name)) + const text = ` +You are continuing a conversation started by a different model. Some tools it used are not available to you. ${list(removed)} ${removed.length === 1 ? "is" : "are"} no longer available and must not be called${added.length > 0 ? `; use ${list(added)} instead` : ""}. +` + + // Before the user's prompt, matching where agent-switch reminders land. + const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length + event.messages.splice(at, 0, Message.user(text)) + yield* ctx.session + .synthetic({ sessionID: event.sessionID, text, metadata: { [key]: removed }, resume: false }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist model switch reminder", { sessionID: event.sessionID, cause }), + ), + ) + }), + ) + }), +}) diff --git a/packages/core/test/plugin/model-switch.test.ts b/packages/core/test/plugin/model-switch.test.ts new file mode 100644 index 00000000000..0c96550bbda --- /dev/null +++ b/packages/core/test/plugin/model-switch.test.ts @@ -0,0 +1,200 @@ +import { describe, expect } from "bun:test" +import { Message } from "@opencode/ai" +import type { SessionContext } from "@opencode/plugin/effect/session" +import { DateTime, Effect } from "effect" +import { Agent } from "@opencode/core/agent" +import { Model } from "@opencode/core/model" +import { ModelSwitchPlugin } from "@opencode/core/plugin/model-switch" +import { Provider } from "@opencode/core/provider" +import { Session } from "@opencode/core/session" +import { SessionInbox } from "@opencode/core/session/inbox" +import { SessionMessage } from "@opencode/core/session/message" +import { Money } from "@opencode/schema/money" +import { it } from "../lib/effect" +import { host } from "./host" + +const sessionID = Session.ID.make("ses_model_switch_test") +const provider = Provider.ID.make("test") +const gpt = { providerID: provider, id: Model.ID.make("gpt-6-luna") } +const claude = { providerID: provider, id: Model.ID.make("claude-fable-5-1") } +const glm = { providerID: provider, id: Model.ID.make("glm-5.3-flash") } +const at = DateTime.makeUnsafe(0) + +const patchNotice = [ + "", + "You are continuing a conversation started by a different model. Some tools it used are not available to you. `patch` is no longer available and must not be called; use `edit` and `write` instead.", + "", +].join("\n") + +const editNotice = [ + "", + "You are continuing a conversation started by a different model. Some tools it used are not available to you. `edit` and `write` are no longer available and must not be called; use `patch` instead.", + "", +].join("\n") + +/** One assistant turn in the durable history, with the tools it called. */ +const turn = (id: string, model: Model.Ref, tools: ReadonlyArray) => + SessionMessage.Assistant.make({ + id: SessionMessage.ID.make(id), + type: "assistant", + agent: Agent.ID.make("build"), + model, + content: tools.map((name, index) => ({ + type: "tool", + id: `call_${id}_${index}`, + name, + state: { status: "completed", input: {}, content: [{ type: "text", text: "ok" }] }, + time: { created: at, completed: at }, + })), + cost: Money.USD.make(0), + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: at, completed: at }, + }) + +/** A reminder this plugin persisted earlier. */ +const reminder = (id: string, removed: ReadonlyArray) => + SessionMessage.Synthetic.make({ + id: SessionMessage.ID.make(id), + type: "synthetic", + text: "earlier reminder", + metadata: { "opencode.model-switch": removed }, + time: { created: at }, + }) + +/** The same history as the model sees it: one tool call per name, plus the user's new prompt. */ +const lowered = (history: ReadonlyArray) => [ + ...history.flatMap((message) => + message.type === "assistant" + ? [ + Message.assistant( + message.content.flatMap((part) => + part.type === "tool" ? [{ type: "tool-call" as const, id: part.id, name: part.name, input: {} }] : [], + ), + ), + ] + : [], + ), + Message.user("Can you also add a --version flag?"), +] + +/** Runs one request through the hook; returns the request messages after it and anything persisted. */ +const request = Effect.fnUntraced(function* ( + model: Model.Ref, + tools: ReadonlyArray, + history: ReadonlyArray, +) { + const persisted = new Array<{ text: string; metadata?: Record; resume?: boolean }>() + let hook: ((input: SessionContext) => Effect.Effect) | undefined + yield* ModelSwitchPlugin.Plugin.effect( + host({ + session: { + hook: (name, callback) => { + if (name === "context") hook = callback as (input: SessionContext) => Effect.Effect + return Effect.succeed({ dispose: Effect.void }) + }, + context: () => Effect.succeed(history), + synthetic: (input) => { + persisted.push({ text: input.text, metadata: input.metadata, resume: input.resume }) + return Effect.succeed( + SessionInbox.Synthetic.make({ + id: SessionMessage.ID.make("msg_model_switch_test"), + sessionID, + time: { created: at }, + type: "synthetic", + payload: { text: input.text }, + delivery: "steer", + }), + ) + }, + }, + }), + ) + if (!hook) return yield* Effect.die("model switch plugin did not register a context hook") + const event: SessionContext = { + sessionID, + agent: Agent.ID.make("build"), + model, + system: [], + messages: lowered(history), + tools: Object.fromEntries(tools.map((name) => [name, { description: name, input: { type: "object" } }])), + options: {}, + } + yield* hook(event) + const injected = event.messages.flatMap((message) => { + const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined + return part?.type === "text" && part.text.startsWith("") ? [part.text] : [] + }) + return { injected, persisted, last: event.messages.at(-1) } +}) + +describe("ModelSwitchPlugin", () => { + it.effect("reminds a model that lacks patch when another model's turns called it", () => + Effect.gen(function* () { + const result = yield* request( + claude, + ["read", "glob", "edit", "write"], + [turn("msg_1", gpt, ["read", "patch"]), turn("msg_2", gpt, ["patch", "glob"])], + ) + expect(result.injected).toEqual([patchNotice]) + expect(result.last?.role).toBe("user") + expect(result.persisted).toEqual([ + { text: patchNotice, metadata: { "opencode.model-switch": ["patch"] }, resume: false }, + ]) + }), + ) + + it.effect("reminds a model that lacks edit and write, with singular phrasing for one tool", () => + Effect.gen(function* () { + const both = yield* request(gpt, ["read", "patch"], [turn("msg_1", claude, ["edit", "write", "read"])]) + expect(both.injected).toEqual([editNotice]) + + const editOnly = yield* request(gpt, ["read", "patch"], [turn("msg_1", claude, ["edit"])]) + expect(editOnly.injected[0]).toContain( + "`edit` is no longer available and must not be called; use `patch` instead.", + ) + }), + ) + + it.effect("only suggests replacements this request actually offers", () => + Effect.gen(function* () { + const result = yield* request(claude, ["read"], [turn("msg_1", gpt, ["patch"])]) + expect(result.injected[0]).toContain("`patch` is no longer available and must not be called.") + expect(result.injected[0]).not.toContain("instead") + }), + ) + + it.effect("stays silent when nothing in the history is missing from this request", () => + Effect.gen(function* () { + // Same tools on both sides of the switch. + expect((yield* request(glm, ["edit", "write"], [turn("msg_1", claude, ["edit", "write"])])).injected).toEqual([]) + // Only read-only tools were used before the switch. + expect( + (yield* request(claude, ["read", "grep", "glob", "edit"], [turn("msg_1", gpt, ["read", "grep", "glob"])])) + .injected, + ).toEqual([]) + // The same model made the calls; a missing tool here is not a model switch. + expect((yield* request(claude, ["read"], [turn("msg_1", claude, ["edit"])])).injected).toEqual([]) + }), + ) + + it.effect("does not repeat a reminder that already covers the missing tools", () => + Effect.gen(function* () { + // GPT wrote with patch, a first non-GPT model was reminded, now a second non-GPT model takes over. + const covered = yield* request( + glm, + ["read", "edit", "write"], + [turn("msg_1", gpt, ["patch"]), reminder("msg_2", ["patch"]), turn("msg_3", claude, ["edit"])], + ) + expect(covered.injected).toEqual([]) + expect(covered.persisted).toEqual([]) + + // Switching back to GPT: the last reminder was about patch, so the edit calls still need one. + const back = yield* request( + gpt, + ["read", "patch"], + [turn("msg_1", gpt, ["patch"]), reminder("msg_2", ["patch"]), turn("msg_3", claude, ["edit"])], + ) + expect(back.injected).toEqual([editNotice.replace("`edit` and `write` are", "`edit` is")]) + }), + ) +})