Skip to content

Commit a443499

Browse files
committed
fix(plan): resolve model from agent config in plan tools
1 parent a93d98b commit a443499

File tree

2 files changed

+129
-3
lines changed

2 files changed

+129
-3
lines changed

packages/opencode/src/tool/plan.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,23 @@ import { Question } from "../question"
55
import { Session } from "../session"
66
import { MessageV2 } from "../session/message-v2"
77
import { Provider } from "../provider/provider"
8+
import { Agent } from "../agent/agent"
89
import { Instance } from "../project/instance"
910
import { type SessionID, MessageID, PartID } from "../session/schema"
1011
import EXIT_DESCRIPTION from "./plan-exit.txt"
1112

12-
async function getLastModel(sessionID: SessionID) {
13+
async function lastModel(sessionID: SessionID) {
1314
for await (const item of MessageV2.stream(sessionID)) {
1415
if (item.info.role === "user" && item.info.model) return item.info.model
1516
}
1617
return Provider.defaultModel()
1718
}
1819

20+
async function resolveModel(agentName: string, sessionID: SessionID) {
21+
const info = await Agent.get(agentName)
22+
return info?.model ?? (await lastModel(sessionID))
23+
}
24+
1925
export const PlanExitTool = Tool.define("plan_exit", {
2026
description: EXIT_DESCRIPTION,
2127
parameters: z.object({}),
@@ -41,7 +47,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
4147
const answer = answers[0]?.[0]
4248
if (answer === "No") throw new Question.RejectedError()
4349

44-
const model = await getLastModel(ctx.sessionID)
50+
const model = await resolveModel("build", ctx.sessionID)
4551

4652
const userMsg: MessageV2.User = {
4753
id: MessageID.ascending(),
@@ -99,7 +105,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
99105
100106
if (answer === "No") throw new Question.RejectedError()
101107
102-
const model = await getLastModel(ctx.sessionID)
108+
const model = await resolveModel("plan", ctx.sessionID)
103109
104110
const userMsg: MessageV2.User = {
105111
id: MessageID.ascending(),
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { test, expect, spyOn, beforeEach, afterEach } from "bun:test"
2+
import { tmpdir } from "../fixture/fixture"
3+
import { Instance } from "../../src/project/instance"
4+
import { Session } from "../../src/session"
5+
import { MessageV2 } from "../../src/session/message-v2"
6+
import { MessageID, PartID } from "../../src/session/schema"
7+
import * as QuestionModule from "../../src/question"
8+
import { PlanExitTool } from "../../src/tool/plan"
9+
10+
const ctx = (sessionID: string) => ({
11+
sessionID: sessionID as any,
12+
messageID: MessageID.ascending(),
13+
callID: "test-call",
14+
agent: "plan",
15+
abort: AbortSignal.any([]),
16+
messages: [],
17+
metadata: async () => {},
18+
ask: async () => {},
19+
})
20+
21+
async function seedPlanMessage(sessionID: string, model: { providerID: string; modelID: string }) {
22+
const msg: MessageV2.User = {
23+
id: MessageID.ascending(),
24+
sessionID: sessionID as any,
25+
role: "user",
26+
time: { created: Date.now() },
27+
agent: "plan",
28+
model: model as any,
29+
}
30+
await Session.updateMessage(msg)
31+
await Session.updatePart({
32+
id: PartID.ascending(),
33+
messageID: msg.id,
34+
sessionID: sessionID as any,
35+
type: "text",
36+
text: "make a plan",
37+
} as any)
38+
}
39+
40+
let askSpy: ReturnType<typeof spyOn>
41+
42+
beforeEach(() => {
43+
askSpy = spyOn(QuestionModule.Question, "ask").mockResolvedValue([["Yes"]])
44+
})
45+
46+
afterEach(() => {
47+
askSpy.mockRestore()
48+
})
49+
50+
test("plan_exit uses agent.build.model from config when set", async () => {
51+
await using tmp = await tmpdir({
52+
git: true,
53+
config: {
54+
agent: {
55+
build: {
56+
model: "openai/gpt-4o",
57+
},
58+
},
59+
},
60+
})
61+
62+
await Instance.provide({
63+
directory: tmp.path,
64+
fn: async () => {
65+
const session = await Session.create({})
66+
await seedPlanMessage(session.id, { providerID: "anthropic", modelID: "claude-3-5-sonnet" })
67+
68+
const tool = await PlanExitTool.init()
69+
await tool.execute({}, ctx(session.id)).catch(() => {
70+
// ignore errors from missing plan file / session state
71+
})
72+
73+
// Find the user message that was written for the build agent
74+
let buildMsg: MessageV2.User | undefined
75+
for await (const item of MessageV2.stream(session.id as any)) {
76+
if (item.info.role === "user" && item.info.agent === "build") {
77+
buildMsg = item.info as MessageV2.User
78+
}
79+
}
80+
81+
expect(buildMsg).toBeDefined()
82+
expect(String(buildMsg!.model.providerID)).toBe("openai")
83+
expect(String(buildMsg!.model.modelID)).toBe("gpt-4o")
84+
85+
await Session.remove(session.id)
86+
},
87+
})
88+
})
89+
90+
test("plan_exit falls back to last session model when agent.build.model is not configured", async () => {
91+
await using tmp = await tmpdir({ git: true })
92+
93+
await Instance.provide({
94+
directory: tmp.path,
95+
fn: async () => {
96+
const session = await Session.create({})
97+
// Seed a plan message with a specific model (simulates the plan session model)
98+
await seedPlanMessage(session.id, { providerID: "anthropic", modelID: "claude-3-5-sonnet" })
99+
100+
const tool = await PlanExitTool.init()
101+
await tool.execute({}, ctx(session.id)).catch(() => {
102+
// ignore errors from missing plan file / session state
103+
})
104+
105+
let buildMsg: MessageV2.User | undefined
106+
for await (const item of MessageV2.stream(session.id as any)) {
107+
if (item.info.role === "user" && item.info.agent === "build") {
108+
buildMsg = item.info as MessageV2.User
109+
}
110+
}
111+
112+
expect(buildMsg).toBeDefined()
113+
// No build model configured → falls back to the last session model
114+
expect(String(buildMsg!.model.providerID)).toBe("anthropic")
115+
expect(String(buildMsg!.model.modelID)).toBe("claude-3-5-sonnet")
116+
117+
await Session.remove(session.id)
118+
},
119+
})
120+
})

0 commit comments

Comments
 (0)