From a443499341a6994cfbee4d2a190dcfaeedfa7eea Mon Sep 17 00:00:00 2001 From: Filipe Ferreira Date: Wed, 18 Mar 2026 23:33:06 +0000 Subject: [PATCH] fix(plan): resolve model from agent config in plan tools --- packages/opencode/src/tool/plan.ts | 12 ++- packages/opencode/test/tool/plan.test.ts | 120 +++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/tool/plan.test.ts diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index e91bc3faa22..d027c196878 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -5,17 +5,23 @@ import { Question } from "../question" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" import { Provider } from "../provider/provider" +import { Agent } from "../agent/agent" import { Instance } from "../project/instance" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" -async function getLastModel(sessionID: SessionID) { +async function lastModel(sessionID: SessionID) { for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user" && item.info.model) return item.info.model } return Provider.defaultModel() } +async function resolveModel(agentName: string, sessionID: SessionID) { + const info = await Agent.get(agentName) + return info?.model ?? (await lastModel(sessionID)) +} + export const PlanExitTool = Tool.define("plan_exit", { description: EXIT_DESCRIPTION, parameters: z.object({}), @@ -41,7 +47,7 @@ export const PlanExitTool = Tool.define("plan_exit", { const answer = answers[0]?.[0] if (answer === "No") throw new Question.RejectedError() - const model = await getLastModel(ctx.sessionID) + const model = await resolveModel("build", ctx.sessionID) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -99,7 +105,7 @@ export const PlanEnterTool = Tool.define("plan_enter", { if (answer === "No") throw new Question.RejectedError() - const model = await getLastModel(ctx.sessionID) + const model = await resolveModel("plan", ctx.sessionID) const userMsg: MessageV2.User = { id: MessageID.ascending(), diff --git a/packages/opencode/test/tool/plan.test.ts b/packages/opencode/test/tool/plan.test.ts new file mode 100644 index 00000000000..3966c10462b --- /dev/null +++ b/packages/opencode/test/tool/plan.test.ts @@ -0,0 +1,120 @@ +import { test, expect, spyOn, beforeEach, afterEach } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID } from "../../src/session/schema" +import * as QuestionModule from "../../src/question" +import { PlanExitTool } from "../../src/tool/plan" + +const ctx = (sessionID: string) => ({ + sessionID: sessionID as any, + messageID: MessageID.ascending(), + callID: "test-call", + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: async () => {}, + ask: async () => {}, +}) + +async function seedPlanMessage(sessionID: string, model: { providerID: string; modelID: string }) { + const msg: MessageV2.User = { + id: MessageID.ascending(), + sessionID: sessionID as any, + role: "user", + time: { created: Date.now() }, + agent: "plan", + model: model as any, + } + await Session.updateMessage(msg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: sessionID as any, + type: "text", + text: "make a plan", + } as any) +} + +let askSpy: ReturnType + +beforeEach(() => { + askSpy = spyOn(QuestionModule.Question, "ask").mockResolvedValue([["Yes"]]) +}) + +afterEach(() => { + askSpy.mockRestore() +}) + +test("plan_exit uses agent.build.model from config when set", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-4o", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await seedPlanMessage(session.id, { providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + + const tool = await PlanExitTool.init() + await tool.execute({}, ctx(session.id)).catch(() => { + // ignore errors from missing plan file / session state + }) + + // Find the user message that was written for the build agent + let buildMsg: MessageV2.User | undefined + for await (const item of MessageV2.stream(session.id as any)) { + if (item.info.role === "user" && item.info.agent === "build") { + buildMsg = item.info as MessageV2.User + } + } + + expect(buildMsg).toBeDefined() + expect(String(buildMsg!.model.providerID)).toBe("openai") + expect(String(buildMsg!.model.modelID)).toBe("gpt-4o") + + await Session.remove(session.id) + }, + }) +}) + +test("plan_exit falls back to last session model when agent.build.model is not configured", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + // Seed a plan message with a specific model (simulates the plan session model) + await seedPlanMessage(session.id, { providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + + const tool = await PlanExitTool.init() + await tool.execute({}, ctx(session.id)).catch(() => { + // ignore errors from missing plan file / session state + }) + + let buildMsg: MessageV2.User | undefined + for await (const item of MessageV2.stream(session.id as any)) { + if (item.info.role === "user" && item.info.agent === "build") { + buildMsg = item.info as MessageV2.User + } + } + + expect(buildMsg).toBeDefined() + // No build model configured → falls back to the last session model + expect(String(buildMsg!.model.providerID)).toBe("anthropic") + expect(String(buildMsg!.model.modelID)).toBe("claude-3-5-sonnet") + + await Session.remove(session.id) + }, + }) +})