diff --git a/packages/opencode/src/session/llm-native.ts b/packages/opencode/src/session/llm-native.ts index 0ecd19a67a..e1c42643ec 100644 --- a/packages/opencode/src/session/llm-native.ts +++ b/packages/opencode/src/session/llm-native.ts @@ -1,5 +1,5 @@ import * as LLMCore from "@opencode-ai/llm/llm" -import type { Message as CoreMessage } from "@opencode-ai/llm/schema" +import type { ContentPart, Message as CoreMessage } from "@opencode-ai/llm/schema" import { Effect, Schema } from "effect" import { ProviderLLMBridge } from "@/provider/llm-bridge" import { ProviderTransform } from "@/provider" @@ -27,6 +27,7 @@ export type RequestInput = { readonly system?: ReadonlyArray readonly messages: ReadonlyArray readonly tools?: ReadonlyArray + readonly toolChoice?: LLMCore.RequestInput["toolChoice"] readonly generation?: LLMCore.RequestInput["generation"] readonly metadata?: Record readonly native?: Record @@ -37,17 +38,84 @@ const isDefined = (value: T | undefined): value is T => value !== undefined const textContent = (message: MessageV2.WithParts) => message.parts.flatMap((part) => (part.type === "text" && !part.ignored ? [LLMCore.text(part.text)] : [])) -const message = (input: MessageV2.WithParts): CoreMessage | undefined => { +const providerMeta = (metadata: Record | undefined) => { + if (!metadata) return undefined + const { providerExecuted: _, ...rest } = metadata + return Object.keys(rest).length > 0 ? rest : undefined +} + +const toolResultValue = (part: MessageV2.ToolPart) => { + if (part.state.status === "completed") { + return { + type: "text" as const, + value: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, + } + } + if (part.state.status === "error") { + const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined + if (typeof output === "string") return { type: "text" as const, value: output } + return { type: "error" as const, value: part.state.error } + } + return { type: "error" as const, value: "[Tool execution was interrupted]" } +} + +const assistantMessages = (input: MessageV2.WithParts) => { + const content: ContentPart[] = [] + const results: CoreMessage[] = [] + for (const part of input.parts) { + if (part.type === "text" && !part.ignored) content.push(LLMCore.text(part.text)) + if (part.type === "reasoning") content.push({ type: "reasoning", text: part.text, metadata: part.metadata }) + if (part.type === "tool") { + const metadata = providerMeta(part.metadata) + content.push( + LLMCore.toolCall({ + id: part.callID, + name: part.tool, + input: part.state.input, + providerExecuted: part.metadata?.providerExecuted === true ? true : undefined, + metadata, + }), + ) + results.push( + LLMCore.toolMessage({ + id: part.callID, + name: part.tool, + result: toolResultValue(part), + providerExecuted: part.metadata?.providerExecuted === true ? true : undefined, + metadata, + }), + ) + } + } + + return [ + content.length === 0 + ? undefined + : LLMCore.message({ + id: input.info.id, + role: "assistant", + content, + native: { + opencodeMessageID: input.info.id, + }, + }), + ...results, + ].filter(isDefined) +} + +const message = (input: MessageV2.WithParts): ReadonlyArray => { + if (input.info.role === "assistant") return assistantMessages(input) + const content = textContent(input) - if (content.length === 0) return undefined - return LLMCore.message({ + if (content.length === 0) return [] + return [LLMCore.message({ id: input.info.id, role: input.info.role, content, native: { opencodeMessageID: input.info.id, }, - }) + })] } export const toolDefinition = (input: { readonly model: Provider.Model; readonly tool: Tool.Def }) => @@ -75,8 +143,9 @@ export const request = Effect.fn("LLMNative.request")(function* (input: RequestI id: input.id, model, system: input.system?.filter((part) => part.trim() !== "").map(LLMCore.system) ?? [], - messages: input.messages.map(message).filter(isDefined), + messages: input.messages.flatMap(message), tools: input.tools?.map((tool) => toolDefinition({ model: input.model, tool })) ?? [], + toolChoice: input.toolChoice, generation: input.generation, metadata: input.metadata, native: { diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 876806d4d8..54dd223568 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { client } from "@opencode-ai/llm/adapter" +import { OpenAIResponses } from "@opencode-ai/llm/provider/openai-responses" import { Effect, Schema } from "effect" import { ModelID, ProviderID } from "../../src/provider/schema" import { LLMNative } from "../../src/session/llm-native" @@ -27,6 +29,29 @@ const textPart = (messageID: MessageID, text: string, input: Partial ({ + id: PartID.ascending(), + sessionID, + messageID, + type: "reasoning", + text, + time: { start: 1 }, +}) + +const toolPart = ( + messageID: MessageID, + input: Partial & Pick, +): 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: { @@ -146,4 +171,114 @@ describe("LLMNative.request", () => { }, }) }) + + test("converts assistant reasoning and tool history", async () => { + const mdl = model() + const provider = ProviderTest.info({ id: ProviderID.openai }, mdl) + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + + const request = await Effect.runPromise( + 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, + }, + ], + }, + ]) + }) + + test("prepares OpenAI Responses text and tool request body", async () => { + const mdl = model() + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + const request = await Effect.runPromise( + 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 = await Effect.runPromise(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, + }) + }) })