From 162b098a1e33990ad253164d97458f81731690c5 Mon Sep 17 00:00:00 2001 From: anandlo <157297269+anandlo@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:25:31 -0400 Subject: [PATCH] fix(session): don't force tool_choice required for reasoning models Structured-output steps (json_schema format) forced toolChoice: "required", which DeepSeek and other thinking-mode providers reject with HTTP 400 ("Thinking mode does not support this tool_choice"). Reasoning-capable models now use the provider default tool choice; the StructuredOutput tool plus autoRetry still drive extraction. Non-reasoning models keep the forced "required" behavior. --- packages/opencode/src/session/prompt.ts | 4 +- .../opencode/test/lib/scripted-llm-server.ts | 9 +- .../test/session/tool-choice-thinking.test.ts | 134 ++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/session/tool-choice-thinking.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 049b1ad0e..123ad8098 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3845,7 +3845,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the tools, activeTools, model, - toolChoice: isLastStep ? "none" : format.type === "json_schema" ? "required" : undefined, + toolChoice: isLastStep ? "none" : format.type === "json_schema" && !model.capabilities.reasoning ? "required" : undefined, agentID: lastUser.agentID, }) .pipe( @@ -4010,7 +4010,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the tools, activeTools, model, - toolChoice: isLastStep ? ("none" as const) : format.type === "json_schema" ? ("required" as const) : undefined, + toolChoice: isLastStep ? ("none" as const) : format.type === "json_schema" && !model.capabilities.reasoning ? ("required" as const) : undefined, agentID: lastUser.agentID, } diff --git a/packages/opencode/test/lib/scripted-llm-server.ts b/packages/opencode/test/lib/scripted-llm-server.ts index ebc989054..5ccfd7645 100644 --- a/packages/opencode/test/lib/scripted-llm-server.ts +++ b/packages/opencode/test/lib/scripted-llm-server.ts @@ -15,6 +15,8 @@ export interface LLMCapture { /** Raw messages array from the OpenAI-compatible request body */ messages: Array<{ role: string; content: unknown }> + /** Wire-level tool_choice: "required" | "auto" | "none" | object | undefined */ + tool_choice?: string | { type: string; function?: { name: string } } | undefined } type ScriptedResponse = { @@ -223,8 +225,11 @@ export function startScriptedLLMServer(responses: ScriptedResponse[]): ScriptedL return new Response("not found", { status: 404 }) } - const body = (await req.json()) as { messages: Array<{ role: string; content: unknown }> } - captures.push({ messages: body.messages }) + const body = (await req.json()) as { + messages: Array<{ role: string; content: unknown }> + tool_choice?: string | { type: string; function?: { name: string } } | undefined + } + captures.push({ messages: body.messages, tool_choice: body.tool_choice }) const response = responses[Math.min(callIdx, responses.length - 1)] callIdx++ diff --git a/packages/opencode/test/session/tool-choice-thinking.test.ts b/packages/opencode/test/session/tool-choice-thinking.test.ts new file mode 100644 index 000000000..679fbc984 --- /dev/null +++ b/packages/opencode/test/session/tool-choice-thinking.test.ts @@ -0,0 +1,134 @@ +/** + * Integration tests: thinking/reasoning models (config `reasoning: true`, + * e.g. DeepSeek v4 via zen-free) reject `tool_choice: "required"` with a + * provider 400 ("Thinking mode does not support this tool_choice"). Under + * `json_schema` output the loop must therefore NOT force "required" for + * reasoning-capable models; non-thinking models keep the forced "required" + * that makes structured extraction deterministic. + * + * Driven through a real Session.prompt(...) against the scripted HTTP LLM stub. + */ + +import path from "path" +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util" +import { tmpdir } from "../fixture/fixture" +import { startScriptedLLMServer, toolCallResponse } from "../lib/scripted-llm-server" + +void Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +function run(fx: Effect.Effect) { + return Effect.runPromise( + fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ) +} + +function writeConfig(dir: string, origin: string, model: string) { + return Bun.write( + path.join(dir, "mimocode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { apiKey: "test-key", baseURL: `${origin}/v1` }, + models: { + "qa-thinking": { reasoning: true }, + "qa-plain": {}, + }, + }, + }, + agent: { build: { model: `alibaba/${model}` } }, + }), + ) +} + +const schema = { + type: "object", + properties: { answer: { type: "number" } }, + required: ["answer"], +} + +const structuredResponse = toolCallResponse({ + id: "call_1", + name: "StructuredOutput", + args: JSON.stringify({ answer: 4 }), +}) + +describe("tool_choice with thinking models (integration)", () => { + test("thinking model: json_schema format must not force tool_choice required", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: structuredResponse }]) + try { + await writeConfig(tmp.path, stub.origin, "qa-thinking") + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "tool-choice-thinking" }) + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "What is 2 + 2?" }], + format: { type: "json_schema", schema, retryCount: 0 }, + }) + expect(stub.captures.length).toBe(1) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error).toBeUndefined() + expect((result.info.structured as any).answer).toBe(4) + } + expect(stub.captures[0].tool_choice).not.toBe("required") + }), + ), + }) + } finally { + await stub.stop() + } + }) + + test("non-thinking model keeps tool_choice required", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: structuredResponse }]) + try { + await writeConfig(tmp.path, stub.origin, "qa-plain") + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "tool-choice-plain" }) + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "What is 2 + 2?" }], + format: { type: "json_schema", schema, retryCount: 0 }, + }) + expect(stub.captures.length).toBe(1) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error).toBeUndefined() + expect((result.info.structured as any).answer).toBe(4) + } + expect(stub.captures[0].tool_choice).toBe("required") + }), + ), + }) + } finally { + await stub.stop() + } + }) +}) \ No newline at end of file