feat(opencode): convert native LLM message history
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user