feat(opencode): convert native LLM message history

This commit is contained in:
Kit Langton
2026-04-26 20:02:13 -04:00
parent 3a94622e76
commit fa2a5d1fdb
2 changed files with 210 additions and 6 deletions
+75 -6
View File
@@ -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<string>
readonly messages: ReadonlyArray<MessageV2.WithParts>
readonly tools?: ReadonlyArray<Tool.Def>
readonly toolChoice?: LLMCore.RequestInput["toolChoice"]
readonly generation?: LLMCore.RequestInput["generation"]
readonly metadata?: Record<string, unknown>
readonly native?: Record<string, unknown>
@@ -37,17 +38,84 @@ const isDefined = <T>(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<string, unknown> | 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<CoreMessage> => {
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: {
@@ -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<MessageV2.T
...input,
})
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: {
@@ -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,
})
})
})