test(opencode): cover native reasoning continuation (#28683)
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||||
import { jsonSchema, tool, type ModelMessage } from "ai"
|
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
|
||||||
import { Effect, Layer, Stream } from "effect"
|
import { Effect, Layer, Stream } from "effect"
|
||||||
import { LLMNative } from "@/session/llm/native-request"
|
import { LLMNative } from "@/session/llm/native-request"
|
||||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const baseModel: Provider.Model = {
|
const baseModel: Provider.Model = {
|
||||||
id: ModelID.make("gpt-5-mini"),
|
id: ModelID.make("gpt-5-mini"),
|
||||||
@@ -69,6 +70,10 @@ const providerInfo: Provider.Info = {
|
|||||||
models: {},
|
models: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||||
|
)
|
||||||
|
|
||||||
function responsesStream(chunks: unknown[]) {
|
function responsesStream(chunks: unknown[]) {
|
||||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
|
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -76,6 +81,72 @@ function responsesStream(chunks: unknown[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NativeRequestInput = Parameters<typeof LLMNative.request>[0]
|
||||||
|
|
||||||
|
const sessionText = (text: string) => ({ type: "text" as const, text })
|
||||||
|
|
||||||
|
const sessionOpenAIReasoning = (
|
||||||
|
text: string,
|
||||||
|
options: {
|
||||||
|
readonly storedAs: "providerMetadata" | "providerOptions"
|
||||||
|
readonly itemId: string
|
||||||
|
readonly encryptedContent: string | null
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const metadata = {
|
||||||
|
openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
|
||||||
|
}
|
||||||
|
if (options.storedAs === "providerMetadata")
|
||||||
|
return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
|
||||||
|
return Object.assign({ type: "reasoning" as const, text }, { providerOptions: metadata })
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionAssistantPart = ReturnType<typeof sessionText> | ReturnType<typeof sessionOpenAIReasoning>
|
||||||
|
|
||||||
|
const storedSession = {
|
||||||
|
user: (content: string): ModelMessage => ({ role: "user", content }),
|
||||||
|
assistant: (content: SessionAssistantPart[]): ModelMessage => ({ role: "assistant", content }),
|
||||||
|
text: sessionText,
|
||||||
|
openaiReasoning: sessionOpenAIReasoning,
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAIResponses = {
|
||||||
|
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||||
|
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||||
|
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
||||||
|
type: "reasoning",
|
||||||
|
id: options.itemId,
|
||||||
|
encrypted_content: options.encryptedContent,
|
||||||
|
summary: [{ type: "summary_text", text }],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareNativeRequest = (input: NativeRequestInput) => LLMClient.prepare(LLMNative.request(input))
|
||||||
|
|
||||||
|
const expectOpenAIResponsesRequest = (input: {
|
||||||
|
readonly history: NativeRequestInput["messages"]
|
||||||
|
readonly providerOptions?: NativeRequestInput["providerOptions"]
|
||||||
|
readonly maxOutputTokens?: NativeRequestInput["maxOutputTokens"]
|
||||||
|
readonly headers?: NativeRequestInput["headers"]
|
||||||
|
readonly expectedBody: unknown
|
||||||
|
}) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(
|
||||||
|
yield* prepareNativeRequest({
|
||||||
|
model: baseModel,
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
messages: input.history,
|
||||||
|
providerOptions: input.providerOptions,
|
||||||
|
maxOutputTokens: input.maxOutputTokens,
|
||||||
|
headers: input.headers,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "openai-responses",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
body: input.expectedBody,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("session.llm-native.request", () => {
|
describe("session.llm-native.request", () => {
|
||||||
test("maps normalized stream inputs to a native LLM request", () => {
|
test("maps normalized stream inputs to a native LLM request", () => {
|
||||||
const messages: ModelMessage[] = [
|
const messages: ModelMessage[] = [
|
||||||
@@ -426,122 +497,163 @@ describe("session.llm-native.request", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
it.effect("native tool wrapper converts thrown errors into typed ToolFailure", () =>
|
||||||
const wrapped = LLMNativeRuntime.nativeTools(
|
Effect.gen(function* () {
|
||||||
{
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
explode: {
|
{
|
||||||
description: "always throws",
|
explode: {
|
||||||
inputSchema: jsonSchema({ type: "object" }),
|
description: "always throws",
|
||||||
execute: async () => {
|
inputSchema: jsonSchema({ type: "object" }),
|
||||||
throw new Error("boom")
|
execute: async () => {
|
||||||
},
|
throw new Error("boom")
|
||||||
} as any,
|
},
|
||||||
},
|
} satisfies Tool,
|
||||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
},
|
||||||
)
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
const failure = await Effect.runPromise(
|
const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" }))
|
||||||
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
)
|
expect(failure.message).toBe("boom")
|
||||||
expect(failure).toBeInstanceOf(ToolFailure)
|
}),
|
||||||
expect((failure as ToolFailure).message).toBe("boom")
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
|
it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () =>
|
||||||
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
Effect.gen(function* () {
|
||||||
// The native runtime owns execution, so encountering such a tool here means upstream
|
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||||
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||||
const wrapped = LLMNativeRuntime.nativeTools(
|
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||||
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool },
|
||||||
)
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
const failure = await Effect.runPromise(
|
const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))
|
||||||
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
)
|
expect(failure.message).toContain("incomplete")
|
||||||
expect(failure).toBeInstanceOf(ToolFailure)
|
}),
|
||||||
expect((failure as ToolFailure).message).toContain("incomplete")
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("compiles through the native OpenAI Responses route", async () => {
|
it.effect("compiles through the native OpenAI Responses route", () =>
|
||||||
const prepared = await Effect.runPromise(
|
expectOpenAIResponsesRequest({
|
||||||
LLMClient.prepare(
|
history: [storedSession.user("hello")],
|
||||||
LLMNative.request({
|
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
||||||
model: baseModel,
|
maxOutputTokens: 512,
|
||||||
apiKey: "test-openai-key",
|
headers: { "x-request": "request-header" },
|
||||||
messages: [{ role: "user", content: "hello" }],
|
expectedBody: {
|
||||||
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
|
||||||
maxOutputTokens: 512,
|
|
||||||
headers: { "x-request": "request-header" },
|
|
||||||
}),
|
|
||||||
).pipe(
|
|
||||||
Effect.provide(LLMClient.layer),
|
|
||||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared).toMatchObject({
|
|
||||||
route: "openai-responses",
|
|
||||||
protocol: "openai-responses",
|
|
||||||
body: {
|
|
||||||
model: "gpt-5-mini",
|
model: "gpt-5-mini",
|
||||||
instructions: "You are concise.",
|
instructions: "You are concise.",
|
||||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
input: [openAIResponses.user("hello")],
|
||||||
max_output_tokens: 512,
|
max_output_tokens: 512,
|
||||||
store: false,
|
store: false,
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("uses provider fetch override for native OpenAI OAuth requests", async () => {
|
it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
|
||||||
const captures: Array<{ url: string; body: unknown }> = []
|
expectOpenAIResponsesRequest({
|
||||||
const customFetch = (async (input, init) => {
|
history: [
|
||||||
const request = input instanceof Request ? input : new Request(input, init)
|
storedSession.user("What changed?"),
|
||||||
captures.push({ url: request.url, body: await request.clone().json() })
|
storedSession.assistant([
|
||||||
return responsesStream([
|
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
storedAs: "providerOptions",
|
||||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
itemId: "rs_1",
|
||||||
])
|
encryptedContent: null,
|
||||||
}) as typeof fetch
|
}),
|
||||||
|
storedSession.text("The parser changed."),
|
||||||
const events = await Effect.runPromise(
|
]),
|
||||||
Effect.gen(function* () {
|
storedSession.user("Summarize it."),
|
||||||
const llmClient = yield* LLMClient.Service
|
],
|
||||||
const native = LLMNativeRuntime.stream({
|
providerOptions: { openai: { store: false } },
|
||||||
model: baseModel,
|
expectedBody: {
|
||||||
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
input: [
|
||||||
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
openAIResponses.user("What changed?"),
|
||||||
llmClient,
|
openAIResponses.assistant("The parser changed."),
|
||||||
messages: [{ role: "user", content: "hello" }],
|
openAIResponses.user("Summarize it."),
|
||||||
tools: {},
|
],
|
||||||
providerOptions: { instructions: "You are concise." },
|
store: false,
|
||||||
headers: {},
|
|
||||||
abort: new AbortController().signal,
|
|
||||||
})
|
|
||||||
expect(native.type).toBe("supported")
|
|
||||||
if (native.type === "unsupported") return []
|
|
||||||
return yield* native.stream.pipe(Stream.runCollect)
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(LLMClient.layer),
|
|
||||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(captures).toHaveLength(1)
|
|
||||||
expect(captures[0]).toMatchObject({
|
|
||||||
url: "https://api.openai.com/v1/responses",
|
|
||||||
body: {
|
|
||||||
model: "gpt-5-mini",
|
|
||||||
instructions: "You are concise.",
|
|
||||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
expect(events).toEqual(
|
)
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
it.effect("preserves encrypted OpenAI reasoning state through native request lowering", () =>
|
||||||
expect.objectContaining({ type: "finish" }),
|
expectOpenAIResponsesRequest({
|
||||||
]),
|
history: [
|
||||||
)
|
storedSession.user("What changed?"),
|
||||||
})
|
storedSession.assistant([
|
||||||
|
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||||
|
storedAs: "providerMetadata",
|
||||||
|
itemId: "rs_1",
|
||||||
|
encryptedContent: "encrypted-state",
|
||||||
|
}),
|
||||||
|
storedSession.text("The parser changed."),
|
||||||
|
]),
|
||||||
|
storedSession.user("Summarize it."),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
||||||
|
expectedBody: {
|
||||||
|
input: [
|
||||||
|
openAIResponses.user("What changed?"),
|
||||||
|
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
||||||
|
itemId: "rs_1",
|
||||||
|
encryptedContent: "encrypted-state",
|
||||||
|
}),
|
||||||
|
openAIResponses.assistant("The parser changed."),
|
||||||
|
openAIResponses.user("Summarize it."),
|
||||||
|
],
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
|
store: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const captures: Array<{ url: string; body: unknown }> = []
|
||||||
|
const customFetch = Object.assign(
|
||||||
|
async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
captures.push({ url: request.url, body: await request.clone().json() })
|
||||||
|
return responsesStream([
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{ preconnect: () => undefined },
|
||||||
|
) satisfies typeof fetch
|
||||||
|
|
||||||
|
const llmClient = yield* LLMClient.Service
|
||||||
|
const native = LLMNativeRuntime.stream({
|
||||||
|
model: baseModel,
|
||||||
|
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
||||||
|
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||||
|
llmClient,
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
tools: {},
|
||||||
|
providerOptions: { instructions: "You are concise." },
|
||||||
|
headers: {},
|
||||||
|
abort: new AbortController().signal,
|
||||||
|
})
|
||||||
|
expect(native.type).toBe("supported")
|
||||||
|
if (native.type === "unsupported") throw new Error(native.reason)
|
||||||
|
const events = Array.from(yield* native.stream.pipe(Stream.runCollect))
|
||||||
|
|
||||||
|
expect(captures).toHaveLength(1)
|
||||||
|
expect(captures[0]).toMatchObject({
|
||||||
|
url: "https://api.openai.com/v1/responses",
|
||||||
|
body: {
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
instructions: "You are concise.",
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
||||||
|
expect.objectContaining({ type: "finish" }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user