summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 20:44:09 +0900
committerAdam Malczewski <[email protected]>2026-05-19 20:44:09 +0900
commitb3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0 (patch)
tree480a1fa2c95bf87ab214f709b1b4414aa64e8929
parentf78a91c20f658dd404277919a0b872b352c99bb6 (diff)
downloaddispatch-b3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0.tar.gz
dispatch-b3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0.zip
fix: DeepSeek reasoning_content dropped on multi-step tool calls
- Base URL corrected: zen/v1 -> zen/go/v1 (opencode-go provider) - Model changed: deepseek-v4-flash-free -> deepseek-v4-flash - Added wrapLanguageModel middleware to inject reasoning_content via providerMetadata.openaiCompatible before each stream call - Fixed test mocks: removed vi.importActual (unsupported in Bun), added tool factory mocks, preserved real tool export in ai mock - Added 11 tests for the normalizeMessages middleware
-rwxr-xr-xbin/prod_secrets2
-rw-r--r--docker-compose.prod.yml2
-rw-r--r--docker-compose.yml2
-rw-r--r--packages/api/src/agent-manager.ts4
-rw-r--r--packages/api/tests/agent-manager.test.ts64
-rw-r--r--packages/api/tests/routes.test.ts65
-rw-r--r--packages/core/src/llm/provider.ts67
-rw-r--r--packages/core/tests/agent/agent.test.ts4
-rw-r--r--packages/core/tests/llm/provider.test.ts284
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts2
-rw-r--r--problem.md79
11 files changed, 522 insertions, 53 deletions
diff --git a/bin/prod_secrets b/bin/prod_secrets
index 68cc6d9..17e35c2 100755
--- a/bin/prod_secrets
+++ b/bin/prod_secrets
@@ -30,4 +30,4 @@ echo "" >&2
# --- Output .env format to stdout ---
echo "OPENCODE_API_KEY=$(gopass show -o projects/ai-api/opencode_go_key)"
-echo "DISPATCH_MODEL=deepseek-v4-flash-free"
+echo "DISPATCH_MODEL=deepseek-v4-flash"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 2809e01..35234b8 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -7,7 +7,7 @@ services:
- "3000:3000"
environment:
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
- DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash-free}
+ DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash}
DISPATCH_WORKING_DIR: /app/workspace
volumes:
- workspace:/app/workspace
diff --git a/docker-compose.yml b/docker-compose.yml
index 770c83a..e52d3f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,7 @@ services:
- .:/app
environment:
OPENCODE_API_KEY: ${OPENCODE_API_KEY:-}
- DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash-free}
+ DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash}
DISPATCH_WORKING_DIR: /app
frontend:
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index f60b8d3..2991ee1 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -24,7 +24,7 @@ export class AgentManager {
private getOrCreateAgent(): Agent {
if (!this.agent) {
const apiKey = process.env.OPENCODE_API_KEY ?? "";
- const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash-free";
+ const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
const tools = [
@@ -36,7 +36,7 @@ export class AgentManager {
this.agent = new Agent({
model,
apiKey,
- baseURL: "https://opencode.ai/zen/v1",
+ baseURL: "https://opencode.ai/zen/go/v1",
systemPrompt: SYSTEM_PROMPT,
tools,
workingDirectory,
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 17b0bff..56cb818 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -1,28 +1,48 @@
-import type { AgentEvent } from "@dispatch/core";
+import type { AgentEvent, ToolDefinition } from "@dispatch/core";
import { describe, expect, it, vi } from "vitest";
// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", async () => {
- const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
- return {
- ...actual,
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- async *run(_message: string) {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 10));
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: { role: "assistant", content: "Hello world" },
- } as const;
- yield { type: "status", status: "idle" } as const;
- }
- },
- };
-});
+vi.mock("@dispatch/core", () => ({
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ await new Promise<void>((r) => setTimeout(r, 10));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ createReadFileTool(_wd: string): ToolDefinition {
+ return {
+ name: "read_file",
+ description: "read a file",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock file content",
+ };
+ },
+ createWriteFileTool(_wd: string): ToolDefinition {
+ return {
+ name: "write_file",
+ description: "write a file",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => true,
+ };
+ },
+ createListFilesTool(_wd: string): ToolDefinition {
+ return {
+ name: "list_files",
+ description: "list files",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => ["file1.ts"],
+ };
+ },
+}));
// Import after mock is defined (Vitest hoists vi.mock automatically)
const { AgentManager } = await import("../src/agent-manager.js");
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index d5384b3..9f852ee 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -1,28 +1,49 @@
+import type { ToolDefinition } from "@dispatch/core";
import { describe, expect, it, vi } from "vitest";
// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", async () => {
- const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
- return {
- ...actual,
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- async *run(_message: string) {
- yield { type: "status", status: "running" } as const;
- // Simulate some processing time so status stays "running"
- await new Promise<void>((r) => setTimeout(r, 100));
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: { role: "assistant", content: "Hello world" },
- } as const;
- yield { type: "status", status: "idle" } as const;
- }
- },
- };
-});
+vi.mock("@dispatch/core", () => ({
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ // Simulate some processing time so status stays "running"
+ await new Promise<void>((r) => setTimeout(r, 100));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ createReadFileTool(_wd: string): ToolDefinition {
+ return {
+ name: "read_file",
+ description: "read a file",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock file content",
+ };
+ },
+ createWriteFileTool(_wd: string): ToolDefinition {
+ return {
+ name: "write_file",
+ description: "write a file",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => true,
+ };
+ },
+ createListFilesTool(_wd: string): ToolDefinition {
+ return {
+ name: "list_files",
+ description: "list files",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => ["file1.ts"],
+ };
+ },
+}));
const { app } = await import("../src/app.js");
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
index 06f7b72..2a917b2 100644
--- a/packages/core/src/llm/provider.ts
+++ b/packages/core/src/llm/provider.ts
@@ -1,9 +1,74 @@
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+import { wrapLanguageModel } from "ai";
+import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai";
+
+/**
+ * Normalize messages for interleaved reasoning providers (DeepSeek).
+ * Extracts { type: "reasoning" } / { type: "redacted-reasoning" } parts from
+ * assistant message content arrays and moves them into
+ * providerMetadata.openaiCompatible.reasoning_content so
+ * @ai-sdk/openai-compatible serializes them correctly for the API.
+ *
+ * IMPORTANT: The messages in params.prompt are already in LanguageModelV1Prompt
+ * format (post-conversion by the AI SDK). They use `providerMetadata`, NOT
+ * `providerOptions` — that conversion happens before the middleware runs.
+ */
+function normalizeMessages(msgs: unknown[]): unknown[] {
+ return msgs.map((msg: unknown) => {
+ const message = msg as Record<string, unknown>;
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
+
+ const content = message.content as Array<Record<string, unknown>>;
+ const reasoningParts = content.filter(
+ (p) => p.type === "reasoning" || p.type === "redacted-reasoning",
+ );
+ const reasoningText = reasoningParts.map((p) => p.text ?? "").join("");
+
+ const filteredContent = content.filter(
+ (p) => p.type !== "reasoning" && p.type !== "redacted-reasoning",
+ );
+
+ const existingMetadata = (message.providerMetadata ?? {}) as Record<string, unknown>;
+ const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<string, unknown>;
+
+ return {
+ ...message,
+ content: filteredContent,
+ providerMetadata: {
+ ...existingMetadata,
+ openaiCompatible: {
+ ...existingOpenAICompat,
+ reasoning_content: reasoningText,
+ },
+ },
+ };
+ });
+}
export function createProvider(config: { apiKey: string; baseURL: string }) {
- return createOpenAICompatible({
+ const provider = createOpenAICompatible({
name: "opencode-zen",
apiKey: config.apiKey,
baseURL: config.baseURL,
});
+
+ return (modelId: string) => {
+ const middleware: LanguageModelV1Middleware = {
+ middlewareVersion: "v1" as const,
+ transformParams: async ({ type, params }) => {
+ if (type === "stream" && params.prompt) {
+ return {
+ ...params,
+ prompt: normalizeMessages(params.prompt as unknown[]) as LanguageModelV1Prompt,
+ };
+ }
+ return params;
+ },
+ };
+
+ return wrapLanguageModel({
+ model: provider(modelId),
+ middleware: [middleware],
+ });
+ };
}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 81d42c8..92df90e 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -4,8 +4,8 @@ import { Agent } from "../../src/agent/agent.js";
import type { AgentConfig } from "../../src/types/index.js";
// Mock the ai module's streamText
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal<typeof import("ai")>();
+vi.mock("ai", async () => {
+ const actual = await import("ai");
return {
...actual,
streamText: vi.fn(),
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
new file mode 100644
index 0000000..9b5439e
--- /dev/null
+++ b/packages/core/tests/llm/provider.test.ts
@@ -0,0 +1,284 @@
+import { describe, expect, it, vi } from "vitest";
+
+// We test normalizeMessages through the middleware by mocking the provider
+// layers and capturing what transformParams does to the prompt.
+
+// Mock wrapLanguageModel to capture the middleware
+vi.mock("ai", async () => {
+ const actual = await import("ai");
+ return {
+ ...actual,
+ wrapLanguageModel: vi.fn(({ model, middleware }) => {
+ // Return a wrapper that exposes the middleware for testing
+ const wrapped = actual.wrapLanguageModel({ model, middleware });
+ (wrapped as unknown as Record<string, unknown>)._middleware = middleware;
+ return wrapped;
+ }),
+ streamText: vi.fn(),
+ };
+});
+
+// Mock provider factory
+vi.mock("@ai-sdk/openai-compatible", () => ({
+ createOpenAICompatible: vi.fn(() => (modelId: string) => ({
+ id: `mock-${modelId}`,
+ doGenerate: vi.fn(),
+ doStream: vi.fn(),
+ })),
+}));
+
+const { createProvider } = await import("../../src/llm/provider.js");
+
+// A helper that runs the middleware's transformParams on a prompt
+// and returns the resulting normalized prompt.
+async function runTransform(
+ prompt: unknown[],
+): Promise<unknown[]> {
+ const wrappedModel = createProvider({
+ apiKey: "test-key",
+ baseURL: "https://example.com/v1",
+ })("test-model");
+
+ const middleware = (
+ (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> }
+ )._middleware;
+
+ const result = await middleware[0]!.transformParams({
+ type: "stream",
+ params: { prompt },
+ });
+
+ return (result as Record<string, unknown>).prompt as unknown[];
+}
+
+describe("createProvider middleware", () => {
+ it("passes through non-stream calls unchanged", async () => {
+ const wrappedModel = createProvider({
+ apiKey: "test-key",
+ baseURL: "https://example.com/v1",
+ })("test-model");
+
+ const middleware = (
+ (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> }
+ )._middleware;
+
+ const params = { prompt: [], temperature: 0.5 };
+ const result = (await middleware[0]!.transformParams({
+ type: "generate",
+ params,
+ })) as Record<string, unknown>;
+
+ expect(result).toEqual(params);
+ });
+
+ it("strips reasoning parts and sets reasoning_content on providerMetadata", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "I should use the list_files tool." },
+ { type: "text", text: "Let me check the directory." },
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "list_files",
+ args: { path: "." },
+ },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ const msg = normalized[0] as Record<string, unknown>;
+
+ // Reasoning parts removed from content
+ const content = msg.content as Array<Record<string, unknown>>;
+ expect(content).toHaveLength(2);
+ expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
+ expect(content.find((p) => p.type === "text")).toBeDefined();
+ expect(content.find((p) => p.type === "tool-call")).toBeDefined();
+
+ // reasoning_content set on providerMetadata
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+ expect(compat.reasoning_content).toBe("I should use the list_files tool.");
+ });
+
+ it("sets empty reasoning_content when no reasoning parts exist", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Hello!" },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ const msg = normalized[0] as Record<string, unknown>;
+
+ // Content unchanged
+ const content = msg.content as Array<Record<string, unknown>>;
+ expect(content).toHaveLength(1);
+ expect(content[0]!.type).toBe("text");
+
+ // reasoning_content always set, even empty
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+ expect(compat.reasoning_content).toBe("");
+ });
+
+ it("does not modify user messages", async () => {
+ const prompt = [
+ {
+ role: "user",
+ content: [{ type: "text", text: "What dir am I in?" }],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ expect(normalized).toEqual(prompt);
+ });
+
+ it("does not modify system messages", async () => {
+ const prompt = [
+ { role: "system", content: "You are a helpful assistant." },
+ ];
+
+ const normalized = await runTransform(prompt);
+ expect(normalized).toEqual(prompt);
+ });
+
+ it("handles assistant with plain string content (not array)", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: "Hello world",
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ expect(normalized).toEqual(prompt);
+ });
+
+ it("handles redacted-reasoning type parts", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "redacted-reasoning", text: "[redacted chain of thought]" },
+ { type: "text", text: "Here is the result." },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ const msg = normalized[0] as Record<string, unknown>;
+
+ const content = msg.content as Array<Record<string, unknown>>;
+ expect(content.find((p) => p.type === "redacted-reasoning")).toBeUndefined();
+ expect(content.find((p) => p.type === "text")).toBeDefined();
+
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+ expect(compat.reasoning_content).toBe("[redacted chain of thought]");
+ });
+
+ it("preserves existing providerMetadata fields", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "thinking..." },
+ { type: "text", text: "done" },
+ ],
+ providerMetadata: {
+ openaiCompatible: { custom_field: "keep-me" },
+ },
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ const msg = normalized[0] as Record<string, unknown>;
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+
+ expect(compat.custom_field).toBe("keep-me");
+ expect(compat.reasoning_content).toBe("thinking...");
+ });
+
+ it("handles multi-message prompts with mixed roles", async () => {
+ const prompt = [
+ { role: "system", content: "Be helpful." },
+ { role: "user", content: [{ type: "text", text: "hi" }] },
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "I'll say hello." },
+ { type: "text", text: "Hi there!" },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+
+ // System and user unchanged
+ expect(normalized[0]).toEqual(prompt[0]);
+ expect(normalized[1]).toEqual(prompt[1]);
+
+ // Assistant transformed
+ const msg = normalized[2] as Record<string, unknown>;
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+ expect(compat.reasoning_content).toBe("I'll say hello.");
+ });
+
+ it("concatenates multiple reasoning parts", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "Step 1: " },
+ { type: "reasoning", text: "Step 2: " },
+ { type: "reasoning", text: "Step 3." },
+ { type: "text", text: "Final answer." },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+ const msg = normalized[0] as Record<string, unknown>;
+ const pm = msg.providerMetadata as Record<string, unknown>;
+ const compat = pm.openaiCompatible as Record<string, unknown>;
+ expect(compat.reasoning_content).toBe("Step 1: Step 2: Step 3.");
+ });
+
+ it("applies to every assistant message in a multi-step history", async () => {
+ const prompt = [
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "First thought." },
+ { type: "tool-call", toolCallId: "c1", toolName: "list_files", args: {} },
+ ],
+ },
+ {
+ role: "assistant",
+ content: [
+ { type: "reasoning", text: "Second thought." },
+ { type: "text", text: "All done." },
+ ],
+ },
+ ];
+
+ const normalized = await runTransform(prompt);
+
+ const msg1 = normalized[0] as Record<string, unknown>;
+ const compat1 = (msg1.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>;
+ expect(compat1.reasoning_content).toBe("First thought.");
+
+ const msg2 = normalized[1] as Record<string, unknown>;
+ const compat2 = (msg2.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>;
+ expect(compat2.reasoning_content).toBe("Second thought.");
+ });
+});
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts
index 54216ec..e5367c2 100644
--- a/packages/frontend/src/lib/chat.svelte.ts
+++ b/packages/frontend/src/lib/chat.svelte.ts
@@ -9,7 +9,7 @@ function generateId() {
function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo {
return {
timestamp: new Date().toISOString(),
- model: "deepseek-v4-flash-free",
+ model: "deepseek-v4-flash",
apiBase: config.apiBase,
connectionStatus: wsClient.connectionStatus,
...overrides,
diff --git a/problem.md b/problem.md
new file mode 100644
index 0000000..a29f801
--- /dev/null
+++ b/problem.md
@@ -0,0 +1,79 @@
+# Problem: DeepSeek `reasoning_content` Dropped on Multi-Step Tool Calls
+
+## Symptom
+
+The first LLM call works (model makes a tool call). The second call fails with:
+
+```
+Error from provider (DeepSeek): The `reasoning_content` in the thinking mode must be passed back to the API.
+```
+
+This only happens when `maxSteps > 1` in `streamText` — i.e., when the agent loop calls the LLM a second time after executing a tool.
+
+## Root Cause
+
+A bug in `@ai-sdk/openai-compatible`. The package correctly **receives** `reasoning_content` from DeepSeek's response but silently **drops** it when building the next request.
+
+### The chain of events:
+
+1. **DeepSeek responds** with both `reasoning_content` (chain-of-thought) and `content` (answer) plus tool calls.
+
+2. **`@ai-sdk/openai-compatible` parses the response** and correctly captures `reasoning_content` into the SDK's internal `reasoning` field.
+
+3. **The AI SDK stores it** as a `{ type: "reasoning", text: "..." }` content part on the assistant message — this is correct.
+
+4. **On the next step**, the SDK passes the message history back through `@ai-sdk/openai-compatible`'s `convertToOpenAICompatibleChatMessages()` to serialize it for the API. This function handles assistant message content parts with a switch statement:
+
+ ```js
+ // @ai-sdk/openai-compatible/dist/index.js, lines 90-120
+ for (const part of content) {
+ switch (part.type) {
+ case "text": { /* handled */ break; }
+ case "tool-call": { /* handled */ break; }
+ // NO case "reasoning" — silently dropped!
+ }
+ }
+ ```
+
+5. **The outgoing request** has no `reasoning_content` field. DeepSeek requires it to be echoed back and rejects the request.
+
+### Important: The agent code cannot fix this
+
+The `streamText` function with `maxSteps` manages its own internal multi-step loop. The agent's `toCoreMessages()` is only called once for the initial prompt. The second call to DeepSeek is built entirely inside the SDK — the serialization bug is in `@ai-sdk/openai-compatible`, not in our code.
+
+## Fix Options
+
+### Option A: Patch `@ai-sdk/openai-compatible` (recommended)
+
+Add a `case "reasoning"` branch to `convertToOpenAICompatibleChatMessages()` that writes `reasoning_content` back into the outgoing assistant message:
+
+```js
+case "reasoning": {
+ reasoningContent = (reasoningContent ?? "") + part.text;
+ break;
+}
+// Then in the push:
+messages.push({
+ role: "assistant",
+ content: text,
+ reasoning_content: reasoningContent ?? undefined,
+ tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
+ ...metadata
+});
+```
+
+Apply via `bun patch` or `patch-package`. File a bug/PR upstream against `@ai-sdk/openai-compatible`.
+
+### Option B: AI middleware to strip reasoning
+
+Use the AI SDK's `wrapLanguageModel` to intercept responses and remove `reasoning` parts before they enter the multi-step history. This avoids the error but loses the chain-of-thought content. Acceptable for Phase 1 since we don't display reasoning in the UI.
+
+### Option C: Switch to a model without thinking mode
+
+Use a DeepSeek model or configuration that doesn't enable thinking mode, if one is available through the OpenCode Zen endpoint. This avoids the problem entirely but limits model capability.
+
+## Affected Files
+
+- Bug location: `node_modules/@ai-sdk/openai-compatible/dist/index.js` (lines 90-120 in `convertToOpenAICompatibleChatMessages`)
+- Our agent code: `packages/core/src/agent/agent.ts` — not the cause, cannot fix it from here
+- Upstream repo: https://github.com/vercel/ai (the `@ai-sdk/openai-compatible` package)