Files
anomalyco_opencode/packages/opencode/test/session/llm-native.test.ts
T
2026-05-01 08:12:34 -04:00

428 lines
13 KiB
TypeScript

import { describe, expect } from "bun:test"
import { AnthropicMessages } from "@opencode-ai/llm"
import { client } from "@opencode-ai/llm/adapter"
import { OpenAIResponses } from "@opencode-ai/llm/provider/openai-responses"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { LLMNative } from "../../src/session/llm-native"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
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"
const sessionID = SessionID.descending()
const model = (input: Partial<Provider.Model> = {}) =>
ProviderTest.model({
id: ModelID.make("gpt-5"),
providerID: ProviderID.openai,
api: { id: "gpt-5", url: "https://api.openai.com/v1", npm: "@ai-sdk/openai" },
...input,
})
const textPart = (messageID: MessageID, text: string, input: Partial<MessageV2.TextPart> = {}): MessageV2.TextPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "text",
text,
...input,
})
const filePart = (messageID: MessageID): MessageV2.FilePart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "file",
mime: "image/png",
url: "data:image/png;base64,abc",
})
const reasoningPart = (messageID: MessageID, text: string): MessageV2.ReasoningPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "reasoning",
text,
time: { start: 1 },
})
const toolPart = (
messageID: MessageID,
input: Partial<MessageV2.ToolPart> & Pick<MessageV2.ToolPart, "callID" | "tool" | "state">,
): MessageV2.ToolPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "tool",
callID: input.callID,
tool: input.tool,
state: input.state,
metadata: input.metadata,
})
const userMessage = (mdl: Provider.Model, id: MessageID, parts: MessageV2.Part[]): MessageV2.WithParts => {
return {
info: {
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: mdl.providerID, modelID: mdl.id },
},
parts,
}
}
const assistantMessage = (
mdl: Provider.Model,
id: MessageID,
parentID: MessageID,
parts: MessageV2.Part[],
): MessageV2.WithParts => {
return {
info: {
id,
sessionID,
role: "assistant",
time: { created: 2 },
parentID,
modelID: mdl.id,
providerID: mdl.providerID,
mode: "build",
agent: "build",
path: { cwd: "/tmp/project", root: "/tmp/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts,
}
}
const lookupParameters = Schema.Struct({
query: Schema.String.annotate({ description: "Search query" }),
})
const lookupTool = {
id: "lookup",
description: "Lookup project data",
parameters: lookupParameters,
execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }),
} satisfies Tool.Def<typeof lookupParameters>
const it = testEffect(Layer.empty)
describe("LLMNative.request", () => {
it.effect("builds a text-only native LLM request", () => Effect.gen(function* () {
const mdl = model()
const provider = ProviderTest.info({ id: ProviderID.openai, key: "openai-key" }, mdl)
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
id: "request-1",
provider,
model: mdl,
system: ["You are concise.", ""],
generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 },
messages: [
userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]),
assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]),
],
})
expect(request).toMatchObject({
id: "request-1",
model: {
id: "gpt-5",
provider: "openai",
protocol: "openai-responses",
headers: { authorization: "Bearer openai-key" },
},
system: [{ type: "text", text: "You are concise." }],
generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 },
tools: [],
})
expect(request.messages.map((message) => ({ id: message.id, role: message.role, content: message.content }))).toEqual([
{ id: userID, role: "user", content: [{ type: "text", text: "Hello" }] },
{ id: assistantID, role: "assistant", content: [{ type: "text", text: "Hi" }] },
])
}))
it.effect("converts native tool definitions", () => Effect.gen(function* () {
const mdl = model()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [],
tools: [lookupTool],
})
expect(request.tools).toHaveLength(1)
expect(request.tools[0]).toMatchObject({
name: "lookup",
description: "Lookup project data",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
},
required: ["query"],
},
native: {
opencodeToolID: "lookup",
},
})
}))
it.effect("converts assistant reasoning and tool history", () => Effect.gen(function* () {
const mdl = model()
const provider = ProviderTest.info({ id: ProviderID.openai }, mdl)
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider,
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPart(assistantID, "Need a lookup."),
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: "sunny",
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
})
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "Check weather" }] },
{
role: "assistant",
content: [
{ type: "reasoning", text: "Need a lookup.", metadata: undefined },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, metadata: undefined },
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call_1",
name: "lookup",
result: { type: "text", value: "sunny" },
metadata: undefined,
},
],
},
])
}))
it.effect("keeps provider-executed tool results on assistant messages", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Search docs")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "ws_1",
tool: "web_search",
metadata: { providerExecuted: true, provider: "openai" },
state: {
status: "completed",
input: { query: "effect" },
output: "found",
title: "Search",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
})
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "Search docs" }] },
{
role: "assistant",
content: [
{
type: "tool-call",
id: "ws_1",
name: "web_search",
input: { query: "effect" },
providerExecuted: true,
metadata: { provider: "openai" },
},
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "text", value: "found" },
providerExecuted: true,
metadata: { provider: "openai" },
},
],
},
])
}))
it.effect("fails instead of dropping unsupported native parts", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const exit = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [filePart(userID)])],
}).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Error)
if (err instanceof Error) {
expect(err.message).toBe(`Native LLM request conversion does not support file parts in message ${userID}`)
}
}
}))
it.effect("prepares OpenAI Responses text and tool request body", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: '{"forecast":"sunny"}',
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
tools: [lookupTool],
toolChoice: "lookup",
})
const prepared = yield* client({ adapters: [OpenAIResponses.adapter] }).prepare(request)
expect(prepared.target).toMatchObject({
model: "gpt-5",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
tools: [
{
type: "function",
name: "lookup",
description: "Lookup project data",
parameters: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
],
tool_choice: { type: "function", name: "lookup" },
stream: true,
})
}))
it.effect("prepares Anthropic Messages text and tool request body", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("claude-sonnet-4-5"),
providerID: ProviderID.make("anthropic"),
api: { id: "claude-sonnet-4-5", url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" },
})
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl),
model: mdl,
system: ["You are concise."],
generation: { maxTokens: 20, temperature: 0 },
messages: [
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: '{"forecast":"sunny"}',
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
tools: [lookupTool],
toolChoice: "lookup",
})
const prepared = yield* client({ adapters: [AnthropicMessages.adapter] }).prepare(request)
expect(request.model).toMatchObject({
provider: "anthropic",
protocol: "anthropic-messages",
headers: { "x-api-key": "anthropic-key" },
})
expect(prepared.target).toMatchObject({
model: "claude-sonnet-4-5",
system: [{ type: "text", text: "You are concise." }],
messages: [
{ role: "user", content: [{ type: "text", text: "What is the weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
],
tools: [
{
name: "lookup",
description: "Lookup project data",
input_schema: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
],
tool_choice: { type: "tool", name: "lookup" },
stream: true,
max_tokens: 20,
temperature: 0,
})
}))
})