feat(opencode): plumb nativeTools through StreamInput (audit gap #4 phase 2 step 2a)
Adds opt-in `nativeTools?: ReadonlyArray<Tool.Def>` to `LLM.StreamInput` so callers that route through the native path can attach typed opencode tool definitions alongside the AI SDK `tools` record. The gate in `runNative` widens accordingly: a session can use the native path when it has zero tools (existing behavior) OR when it explicitly provides `nativeTools` matching its AI SDK `tools` (new opt-in). When `nativeTools` reaches `LLMNative.request`, the existing `toolDefinition` converter folds each `Tool.Def` into the request's `tools` array and the LLM core lowers it onto the wire. This commit deliberately does NOT include the dispatch loop. A session that opts in by setting `nativeTools` and that triggers a `tool-call` from the model will see the call event but no `tool-result` because the native path has no execute handler yet. That's why no production caller populates `nativeTools`: phase 2 step 2b will land the dispatch loop and only then will real production sessions route through here. What this lays in place: - `StreamInput.nativeTools` typed against `Tool.Def[]` from `@/tool`. Aliased to `OpenCodeTool` at the import to dodge a clash with the AI SDK `Tool` type that the same file already imports. - The `runNative` gate flips from "no tools allowed" to "either no tools, or `nativeTools` is supplied". An AI SDK tool count > 0 with `nativeTools` undefined still falls through, so existing production sessions are unaffected. - `LLMNative.request` already accepted `tools: ReadonlyArray<Tool.Def>` and converts via `toolDefinition`. We just forward the input through; no LLM-bridge change. Smoke coverage: a new test in `llm-native-stream.test.ts` builds a typed `Tool.Def` (Effect Schema parameters), routes it through `LLMNative.request` + `LLMClient.prepare`, and asserts the prepared Anthropic target carries the tool as an `input_schema` block with the expected JSON Schema shape. This validates the conversion path that phase 2 step 2b will exercise from inside `runNative`. Verification: opencode typecheck clean; 35/0/0 across the three bridge-area tests (`llm-native.test.ts`, `llm-native-stream.test.ts`, `llm-bridge.test.ts`).
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
ProviderPatch,
|
||||
RequestExecutor,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
@@ -20,6 +20,7 @@ import { ProviderTest } from "../fake/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { Provider } from "../../src/provider"
|
||||
import type { Tool } from "../../src/tool"
|
||||
|
||||
// Inline HTTP layer that returns a single fixed body. Mirrors the
|
||||
// `fixedResponse` helper in `packages/llm/test/lib/http.ts` — duplicated here
|
||||
@@ -148,4 +149,51 @@ describe("LLMNative stream wire-up (audit gap #4 phase 1)", () => {
|
||||
expect(collected.some((event) => event.type === "error")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
// Phase 2 step 2a: verifies a tool-bearing `nativeTools` array reaches the
|
||||
// wire as Anthropic `tools[]` blocks. The model in this fixture answers with
|
||||
// plain text instead of issuing a tool call (we don't yet have dispatch).
|
||||
// This proves tool definitions plumb through `LLMNative.request` →
|
||||
// `LLMRequest` → adapter `prepare` → wire body.
|
||||
it.effect("forwards nativeTools to the wire as Anthropic tools when the gate is open", () =>
|
||||
Effect.gen(function* () {
|
||||
const mdl = anthropicModel()
|
||||
const provider = ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl)
|
||||
const userID = MessageID.ascending()
|
||||
|
||||
const lookupParameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Search query" }),
|
||||
})
|
||||
const lookupTool: Tool.Def<typeof lookupParameters> = {
|
||||
id: "lookup",
|
||||
description: "Lookup project data",
|
||||
parameters: lookupParameters,
|
||||
execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }),
|
||||
}
|
||||
|
||||
const llmRequest = yield* LLMNative.request({
|
||||
id: "smoke-tools",
|
||||
provider,
|
||||
model: mdl,
|
||||
system: ["You are concise."],
|
||||
messages: [userMessage(mdl, userID, [userPart(userID, "Look something up.")])],
|
||||
tools: [lookupTool],
|
||||
})
|
||||
|
||||
const prepared = yield* LLMClient.make({ adapters, patches: ProviderPatch.defaults }).prepare(llmRequest)
|
||||
expect(prepared.target).toMatchObject({
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup project data",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string", description: "Search query" } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user