From 4f455f1869cbd8eb9f1ce60e2e56cec85e3774c8 Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 17 May 2026 14:27:12 -0400 Subject: [PATCH] real tool calls --- .../src/cli/cmd/tui/simulation-mcp.ts | 6 + .../src/testing/simulation/provider.ts | 34 +++- .../src/testing/simulation/service.ts | 6 + .../scripts/08_real_tool_call_patch.json | 50 ++++++ .../test/testing/simulation/service.test.ts | 149 ++++++++++++++++++ 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json diff --git a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts index ee2c7b6e7ac..1da1e6a4a04 100644 --- a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts +++ b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts @@ -129,6 +129,12 @@ const LlmScriptActionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("text"), content: z.string() }), z.object({ type: z.literal("thinking"), content: z.string() }), z.object({ type: z.literal("error"), message: z.string() }), + z.object({ + type: z.literal("tool-call"), + toolCallId: z.string(), + toolName: z.string(), + input: z.any(), + }), ]) const LlmScriptSchema = z.object({ diff --git a/packages/opencode/src/testing/simulation/provider.ts b/packages/opencode/src/testing/simulation/provider.ts index bba69b5f5a7..081f1b4e7c7 100644 --- a/packages/opencode/src/testing/simulation/provider.ts +++ b/packages/opencode/src/testing/simulation/provider.ts @@ -1,4 +1,10 @@ -import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3FinishReason, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3StreamPart, +} from "@ai-sdk/provider" import { simulateReadableStream } from "ai" import { Effect, Layer } from "effect" import { Provider } from "@/provider/provider" @@ -18,7 +24,7 @@ const model: Provider.Model = { temperature: true, reasoning: true, attachment: false, - toolcall: false, + toolcall: true, input: { text: true, audio: false, image: false, video: false, pdf: false }, output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, @@ -75,6 +81,16 @@ function stream(script: LLMScript) { ) continue } + if (item.type === "tool-call") { + const input = JSON.stringify(item.input) + chunks.push( + { type: "tool-input-start", id: item.toolCallId, toolName: item.toolName }, + { type: "tool-input-delta", id: item.toolCallId, delta: input }, + { type: "tool-input-end", id: item.toolCallId }, + { type: "tool-call", toolCallId: item.toolCallId, toolName: item.toolName, input }, + ) + continue + } chunks.push( { type: "text-start", id }, { type: "text-delta", id, delta: item.content }, @@ -122,8 +138,20 @@ function language(simulation: Simulation.Interface): LanguageModelV3 { const script = await nextScript(simulation) const err = error(script) if (err?.type === "error") throw new Error(err.message) + const content: LanguageModelV3Content[] = [] + const textValue = text(script) + if (textValue) content.push({ type: "text", text: textValue }) + for (const item of script.steps[0] ?? []) { + if (item.type !== "tool-call") continue + content.push({ + type: "tool-call", + toolCallId: item.toolCallId, + toolName: item.toolName, + input: JSON.stringify(item.input), + }) + } return { - content: [{ type: "text", text: text(script) }], + content, finishReason: finishReason(script), usage: usage(script), warnings: [], diff --git a/packages/opencode/src/testing/simulation/service.ts b/packages/opencode/src/testing/simulation/service.ts index ce798a8e4c7..5c69afe5c5b 100644 --- a/packages/opencode/src/testing/simulation/service.ts +++ b/packages/opencode/src/testing/simulation/service.ts @@ -47,6 +47,12 @@ export const LLMScriptAction = Schema.Union([ Schema.Struct({ type: Schema.Literal("text"), content: Schema.String }), Schema.Struct({ type: Schema.Literal("thinking"), content: Schema.String }), Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }), + Schema.Struct({ + type: Schema.Literal("tool-call"), + toolCallId: Schema.String, + toolName: Schema.String, + input: Schema.Json, + }), ]) export type LLMScriptAction = typeof LLMScriptAction.Type diff --git a/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json b/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json new file mode 100644 index 00000000000..9f6441b070e --- /dev/null +++ b/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json @@ -0,0 +1,50 @@ +{ + "actions": [ + { "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } }, + { "type": "pressKey", "key": "b" }, + { "type": "writeFile", "path": "src/greeting.ts", "content": "export function greet(name: string) {\n return `Hi, ${name}`\n}\n" }, + { + "type": "enqueueLLM", + "scripts": [ + { + "steps": [ + [ + { "type": "text", "content": "Looking at `src/greeting.ts`, the function currently returns `\"Hi, …\"`. " }, + { "type": "text", "content": "You asked for a friendlier greeting, so I'll change the prefix from `Hi` to `Hello`. " }, + { "type": "text", "content": "I'll keep the template literal and the `name` interpolation untouched. " }, + { "type": "text", "content": "Here's the plan: I'll use the `edit` tool to replace the single return line. " }, + { "type": "text", "content": "Patching `src/greeting.ts` now." }, + { + "type": "tool-call", + "toolCallId": "patch-greeting-1", + "toolName": "edit", + "input": { + "filePath": "/opencode/src/greeting.ts", + "oldString": "return `Hi, ${name}`", + "newString": "return `Hello, ${name}`" + } + } + ] + ], + "usage": { "inputTokens": 320, "outputTokens": 90, "totalTokens": 410 }, + "finish": "tool-calls" + }, + { + "steps": [ + [ + { "type": "text", "content": "Done — `src/greeting.ts` now returns `Hello, ${name}`." } + ] + ], + "usage": { "inputTokens": 140, "outputTokens": 16, "totalTokens": 156 }, + "finish": "stop" + } + ] + }, + { "type": "typeText", "text": "Make the greeting friendlier — use \"Hello\" instead of \"Hi\" in src/greeting.ts." }, + { "type": "pressEnter" }, + { "type": "wait", "ms": 1500 }, + { "type": "typeText", "text": "!cat src/greeting.ts" }, + { "type": "pressEnter" }, + { "type": "wait", "ms": 800 } + ] +} diff --git a/packages/opencode/test/testing/simulation/service.test.ts b/packages/opencode/test/testing/simulation/service.test.ts index ceb1cad01b1..527b9071657 100644 --- a/packages/opencode/test/testing/simulation/service.test.ts +++ b/packages/opencode/test/testing/simulation/service.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { streamText, tool, jsonSchema } from "ai" import { Effect, Layer } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { Provider } from "../../../src/provider/provider" @@ -189,4 +190,152 @@ describe("Simulation", () => { expect((yield* simulation.snapshot()).llmConsumed).toBe(1) }), ) + + it.effect("simulation provider streams queued tool-call actions", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const provider = yield* Provider.Service + const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID))) + const language = yield* provider.getLanguage(model) + + yield* simulation.enqueueLLM({ + scripts: [ + { + steps: [ + [ + { type: "text", content: "I'll write that file for you" }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "write", + input: { filePath: "/opencode/hello.txt", content: "hi" }, + }, + ], + ], + finish: "tool-calls", + }, + ], + }) + + const result = yield* Effect.promise(() => language.doStream({ prompt: [], abortSignal: undefined })) + const reader = result.stream.getReader() + const parts: unknown[] = [] + while (true) { + const next = yield* Effect.promise(() => reader.read()) + if (next.done) break + parts.push(next.value) + } + + const expectedInput = JSON.stringify({ filePath: "/opencode/hello.txt", content: "hi" }) + expect(parts).toContainEqual({ type: "tool-input-start", id: "call-1", toolName: "write" }) + expect(parts).toContainEqual({ type: "tool-input-delta", id: "call-1", delta: expectedInput }) + expect(parts).toContainEqual({ type: "tool-input-end", id: "call-1" }) + expect(parts).toContainEqual({ type: "tool-call", toolCallId: "call-1", toolName: "write", input: expectedInput }) + const finish = parts.find((p: any) => p?.type === "finish") as any + expect(finish?.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" }) + }), + ) + + it.effect("simulation provider doGenerate returns tool-call content", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const provider = yield* Provider.Service + const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID))) + const language = yield* provider.getLanguage(model) + + yield* simulation.enqueueLLM({ + scripts: [ + { + steps: [ + [ + { type: "text", content: "writing now" }, + { + type: "tool-call", + toolCallId: "call-9", + toolName: "write", + input: { filePath: "/opencode/a.txt", content: "x" }, + }, + ], + ], + finish: "tool-calls", + }, + ], + }) + + const result = yield* Effect.promise(() => language.doGenerate({ prompt: [], abortSignal: undefined })) + expect(result.content).toEqual([ + { type: "text", text: "writing now" }, + { + type: "tool-call", + toolCallId: "call-9", + toolName: "write", + input: JSON.stringify({ filePath: "/opencode/a.txt", content: "x" }), + }, + ]) + expect(result.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" }) + }), + ) + + it.effect("simulation model advertises toolcall capability", () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID))) + expect(model.capabilities.toolcall).toBe(true) + }), + ) + + it.effect("AI SDK streamText invokes tool.execute when the simulated provider emits a tool-call", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const provider = yield* Provider.Service + const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID))) + const language = yield* provider.getLanguage(model) + + yield* simulation.enqueueLLM({ + scripts: [ + { + steps: [ + [ + { type: "text", content: "writing now" }, + { + type: "tool-call", + toolCallId: "call-execute-1", + toolName: "write", + input: { filePath: "/opencode/from-tool.txt", content: "hello from tool" }, + }, + ], + ], + finish: "tool-calls", + }, + ], + }) + + const executed: Array<{ filePath: string; content: string }> = [] + const result = streamText({ + model: language, + prompt: "please write the file", + tools: { + write: tool({ + description: "Write a file", + inputSchema: jsonSchema<{ filePath: string; content: string }>({ + type: "object", + properties: { filePath: { type: "string" }, content: { type: "string" } }, + required: ["filePath", "content"], + }), + execute(args) { + executed.push(args) + return Promise.resolve({ ok: true, path: args.filePath }) + }, + }), + }, + }) + + // Drain the stream so streamText runs to completion. + yield* Effect.promise(async () => { + for await (const _ of result.fullStream) void _ + }) + + expect(executed).toEqual([{ filePath: "/opencode/from-tool.txt", content: "hello from tool" }]) + }), + ) })