test(opencode): cover native reasoning continuation (#28683)

This commit is contained in:
Kit Langton
2026-05-21 14:50:37 -04:00
committed by GitHub
parent 0d2de7db65
commit 40da77e77c
+219 -107
View File
@@ -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" }),
]),
)
}),
)
}) })