mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-24 13:25:01 +00:00
1875 lines
64 KiB
TypeScript
1875 lines
64 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
|
import path from "path"
|
|
import { tool, type ModelMessage } from "ai"
|
|
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
|
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
import z from "zod"
|
|
import { LLM } from "../../src/session/llm"
|
|
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
|
import { Auth } from "@/auth"
|
|
import { Config } from "@/config/config"
|
|
import { Provider } from "@/provider/provider"
|
|
import { ProviderTransform } from "@/provider/transform"
|
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
|
import { Plugin } from "@/plugin"
|
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { testEffect } from "../lib/effect"
|
|
import type { Agent } from "../../src/agent/agent"
|
|
import { MessageV2 } from "../../src/session/message-v2"
|
|
import { SessionID, MessageID } from "../../src/session/schema"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { Permission } from "@/permission"
|
|
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
|
import { Session as SessionNs } from "@/session/session"
|
|
|
|
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
|
|
|
|
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<Config.Info> => {
|
|
const { experimental: _experimental, ...configModel } = model
|
|
return {
|
|
enabled_providers: ["openai"],
|
|
provider: {
|
|
openai: {
|
|
name: "OpenAI",
|
|
env: ["OPENAI_API_KEY"],
|
|
npm: "@ai-sdk/openai",
|
|
api: "https://api.openai.com/v1",
|
|
models: {
|
|
[model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
|
|
},
|
|
options: {
|
|
apiKey: "test-openai-key",
|
|
baseURL,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer))
|
|
|
|
// LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy.
|
|
const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain))
|
|
|
|
// drainWith builds an isolated runtime so the custom layer fully owns LLM and
|
|
// its transitive deps — `Effect.provide(layer)` over an existing runtime layers
|
|
// the new services on top, but transitive Service overrides (e.g. RequestExecutor)
|
|
// resolved through the outer LLM.defaultLayer leak through.
|
|
const drainWith = (layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput) =>
|
|
Effect.gen(function* () {
|
|
const ctx = yield* InstanceRef
|
|
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
|
return yield* Effect.promise(() =>
|
|
Effect.runPromise(
|
|
LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)).pipe(
|
|
Effect.provide(layer),
|
|
Effect.provideService(InstanceRef, ctx),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
|
|
function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, flags: Partial<RuntimeFlags.Info> = {}) {
|
|
return LLM.layer.pipe(
|
|
Layer.provide(Auth.defaultLayer),
|
|
Layer.provide(Config.defaultLayer),
|
|
Layer.provide(Provider.defaultLayer),
|
|
Layer.provide(Plugin.defaultLayer),
|
|
Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))),
|
|
Layer.provide(RuntimeFlags.layer(flags)),
|
|
)
|
|
}
|
|
|
|
describe("session.llm.hasToolCalls", () => {
|
|
test("returns false for empty messages array", () => {
|
|
expect(LLM.hasToolCalls([])).toBe(false)
|
|
})
|
|
|
|
test("returns false for messages with only text content", () => {
|
|
const messages: ModelMessage[] = [
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "Hello" }],
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Hi there" }],
|
|
},
|
|
]
|
|
expect(LLM.hasToolCalls(messages)).toBe(false)
|
|
})
|
|
|
|
test("returns true when messages contain tool-call", () => {
|
|
const messages = [
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "Run a command" }],
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "tool-call",
|
|
toolCallId: "call-123",
|
|
toolName: "bash",
|
|
},
|
|
],
|
|
},
|
|
] as ModelMessage[]
|
|
expect(LLM.hasToolCalls(messages)).toBe(true)
|
|
})
|
|
|
|
test("returns true when messages contain tool-result", () => {
|
|
const messages = [
|
|
{
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
toolCallId: "call-123",
|
|
toolName: "bash",
|
|
},
|
|
],
|
|
},
|
|
] as ModelMessage[]
|
|
expect(LLM.hasToolCalls(messages)).toBe(true)
|
|
})
|
|
|
|
test("returns false for messages with string content", () => {
|
|
const messages: ModelMessage[] = [
|
|
{
|
|
role: "user",
|
|
content: "Hello world",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "Hi there",
|
|
},
|
|
]
|
|
expect(LLM.hasToolCalls(messages)).toBe(false)
|
|
})
|
|
|
|
test("returns true when tool-call is mixed with text content", () => {
|
|
const messages = [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "Let me run that command" },
|
|
{
|
|
type: "tool-call",
|
|
toolCallId: "call-456",
|
|
toolName: "read",
|
|
},
|
|
],
|
|
},
|
|
] as ModelMessage[]
|
|
expect(LLM.hasToolCalls(messages)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("session.llm.ai-sdk adapter", () => {
|
|
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
|
|
|
|
const adapt = (events: ReadonlyArray<AISDKAdapterEvent>) => {
|
|
const state = LLMAISDK.adapterState()
|
|
return Effect.runPromise(
|
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
|
)
|
|
}
|
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface
|
|
const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent
|
|
|
|
test("maps AI SDK stream chunks without losing session-visible fields", async () => {
|
|
const metadata = { openai: { itemID: "item-1" } }
|
|
const events = await adapt([
|
|
{ type: "start" },
|
|
{ type: "start-step", request: {}, warnings: [] },
|
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
|
{ type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata },
|
|
{ type: "tool-input-delta", id: "call-1", delta: '{"query":' },
|
|
{ type: "tool-input-delta", id: "call-1", delta: '"weather"}' },
|
|
{ type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } },
|
|
{
|
|
type: "tool-call",
|
|
toolCallId: "call-1",
|
|
toolName: "lookup",
|
|
input: { query: "weather" },
|
|
providerExecuted: true,
|
|
providerMetadata: { openai: { called: true } },
|
|
},
|
|
{
|
|
type: "tool-result",
|
|
toolCallId: "call-1",
|
|
toolName: "lookup",
|
|
input: { query: "weather" },
|
|
output: { title: "Lookup", output: "sunny", metadata: { ok: true } },
|
|
providerExecuted: true,
|
|
providerMetadata: { openai: { result: true } },
|
|
},
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
|
finishReason: "other",
|
|
rawFinishReason: "other",
|
|
usage: {
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 },
|
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 1 },
|
|
},
|
|
providerMetadata: { openai: { step: true } },
|
|
},
|
|
{
|
|
type: "finish",
|
|
finishReason: "other",
|
|
rawFinishReason: "other",
|
|
totalUsage: {
|
|
inputTokens: 11,
|
|
outputTokens: 6,
|
|
totalTokens: 17,
|
|
cachedInputTokens: 4,
|
|
reasoningTokens: 2,
|
|
inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined },
|
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 2 },
|
|
},
|
|
},
|
|
])
|
|
|
|
expect(events).toMatchObject([
|
|
{ type: "step-start", index: 0 },
|
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
|
{ type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata },
|
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' },
|
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' },
|
|
{ type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } },
|
|
{
|
|
type: "tool-call",
|
|
id: "call-1",
|
|
name: "lookup",
|
|
input: { query: "weather" },
|
|
providerExecuted: true,
|
|
providerMetadata: { openai: { called: true } },
|
|
},
|
|
{
|
|
type: "tool-result",
|
|
id: "call-1",
|
|
name: "lookup",
|
|
result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } },
|
|
providerExecuted: true,
|
|
providerMetadata: { openai: { result: true } },
|
|
},
|
|
{
|
|
type: "step-finish",
|
|
index: 0,
|
|
reason: "unknown",
|
|
usage: {
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
reasoningTokens: 1,
|
|
cacheReadInputTokens: 3,
|
|
cacheWriteInputTokens: 2,
|
|
},
|
|
providerMetadata: { openai: { step: true } },
|
|
},
|
|
{
|
|
type: "finish",
|
|
reason: "unknown",
|
|
usage: {
|
|
inputTokens: 11,
|
|
outputTokens: 6,
|
|
totalTokens: 17,
|
|
reasoningTokens: 2,
|
|
cacheReadInputTokens: 4,
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
test("creates stable block ids when AI SDK omits them", async () => {
|
|
const events = await adapt([
|
|
uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }),
|
|
uncheckedAdapterEvent({ type: "text-end" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
|
])
|
|
|
|
expect(events).toMatchObject([
|
|
{ type: "text-delta", id: "text-0", text: "implicit text" },
|
|
{ type: "text-end", id: "text-0" },
|
|
{ type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" },
|
|
{ type: "reasoning-end", id: "reasoning-0" },
|
|
])
|
|
})
|
|
|
|
test("explicitly ignores non-session-visible AI SDK chunks", async () => {
|
|
expect(
|
|
await adapt([
|
|
uncheckedAdapterEvent({ type: "abort" }),
|
|
uncheckedAdapterEvent({ type: "source" }),
|
|
uncheckedAdapterEvent({ type: "file" }),
|
|
uncheckedAdapterEvent({ type: "raw" }),
|
|
uncheckedAdapterEvent({ type: "tool-output-denied" }),
|
|
uncheckedAdapterEvent({ type: "tool-approval-request" }),
|
|
]),
|
|
).toEqual([])
|
|
})
|
|
|
|
test("preserves tool-error cause", async () => {
|
|
const error = new Permission.RejectedError()
|
|
const events = await Effect.runPromise(
|
|
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
|
|
type: "tool-error",
|
|
toolCallId: "call_123",
|
|
toolName: "bash",
|
|
input: {},
|
|
error,
|
|
}),
|
|
)
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toMatchObject({
|
|
type: "tool-error",
|
|
id: "call_123",
|
|
name: "bash",
|
|
message: error.message,
|
|
error,
|
|
})
|
|
})
|
|
|
|
test("emits undefined usage when every AI SDK usage field is missing", async () => {
|
|
// If every numeric field is undefined the translator should signal "no usage info"
|
|
// by emitting undefined, not by polluting the event with usage: {}. Downstream cost
|
|
// telemetry distinguishes "missing" from "zero," so emitting an empty object causes
|
|
// false positives ("usage was tracked, just empty") instead of correct nulls.
|
|
const events = await adapt([
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
|
finishReason: "stop",
|
|
rawFinishReason: "stop",
|
|
providerMetadata: undefined,
|
|
usage: {
|
|
inputTokens: undefined,
|
|
outputTokens: undefined,
|
|
totalTokens: undefined,
|
|
reasoningTokens: undefined,
|
|
cachedInputTokens: undefined,
|
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
|
},
|
|
},
|
|
])
|
|
|
|
expect(events).toHaveLength(1)
|
|
const stepFinish = events[0]
|
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
|
expect(stepFinish.usage).toBeUndefined()
|
|
})
|
|
|
|
test("reuses adapter state cleanly across streams once finish has fired", async () => {
|
|
// adapterState() is meant to be per-stream, but the only thing finish currently clears
|
|
// is toolNames — step, text counters, and the current text/reasoning IDs all leak
|
|
// forward. A caller that reuses a state across two streams sees text-1/reasoning-1/
|
|
// step index 1 on the second stream's first events. The test pins the intended
|
|
// contract: after finish, the same state can be reused and starts fresh.
|
|
const state = LLMAISDK.adapterState()
|
|
const run = (events: ReadonlyArray<AISDKAdapterEvent>) =>
|
|
Effect.runPromise(
|
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
|
)
|
|
|
|
await run([
|
|
{ type: "start-step", request: {}, warnings: [] },
|
|
uncheckedAdapterEvent({ type: "text-delta", text: "first" }),
|
|
uncheckedAdapterEvent({ type: "text-end" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "first reasoning" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "r1", timestamp: new Date(0), modelId: "gpt-test" },
|
|
finishReason: "stop",
|
|
rawFinishReason: "stop",
|
|
providerMetadata: undefined,
|
|
usage: {
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
totalTokens: 2,
|
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
|
},
|
|
},
|
|
{
|
|
type: "finish",
|
|
finishReason: "stop",
|
|
rawFinishReason: "stop",
|
|
totalUsage: {
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
totalTokens: 2,
|
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
|
},
|
|
},
|
|
])
|
|
|
|
const secondStream = await run([
|
|
{ type: "start-step", request: {}, warnings: [] },
|
|
uncheckedAdapterEvent({ type: "text-delta", text: "second" }),
|
|
uncheckedAdapterEvent({ type: "text-end" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "second reasoning" }),
|
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
|
])
|
|
|
|
expect(secondStream).toMatchObject([
|
|
{ type: "step-start", index: 0 },
|
|
{ type: "text-delta", id: "text-0", text: "second" },
|
|
{ type: "text-end", id: "text-0" },
|
|
{ type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" },
|
|
{ type: "reasoning-end", id: "reasoning-0" },
|
|
])
|
|
})
|
|
|
|
// Anthropic emits cache write counts in providerMetadata.anthropic.cacheCreationInputTokens
|
|
// rather than usage.inputTokenDetails.cacheWriteTokens. Session.getUsage falls back to the
|
|
// metadata path — but only if the adapter preserves providerMetadata on step-finish.
|
|
test("preserves providerMetadata on step-finish so Anthropic cache writes survive getUsage", async () => {
|
|
const events = await adapt([
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-3-5-sonnet" },
|
|
finishReason: "stop",
|
|
rawFinishReason: "stop",
|
|
// Anthropic's AI SDK shape: cacheWriteTokens is NOT in usage, it arrives via providerMetadata.
|
|
usage: {
|
|
inputTokens: 1000,
|
|
outputTokens: 500,
|
|
totalTokens: 1500,
|
|
inputTokenDetails: { noCacheTokens: 800, cacheReadTokens: 200, cacheWriteTokens: undefined },
|
|
outputTokenDetails: { textTokens: 500, reasoningTokens: undefined },
|
|
},
|
|
providerMetadata: { anthropic: { cacheCreationInputTokens: 300 } },
|
|
},
|
|
])
|
|
|
|
expect(events).toHaveLength(1)
|
|
const stepFinish = events[0]
|
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
|
expect(stepFinish.providerMetadata).toEqual({ anthropic: { cacheCreationInputTokens: 300 } })
|
|
expect(stepFinish.usage?.cacheWriteInputTokens).toBeUndefined()
|
|
expect(stepFinish.usage?.cacheReadInputTokens).toBe(200)
|
|
|
|
// End-to-end: with the metadata preserved, getUsage extracts cache.write from the fallback path.
|
|
const result = SessionNs.getUsage({
|
|
model: {
|
|
id: "claude-3-5-sonnet",
|
|
providerID: "anthropic",
|
|
name: "Claude",
|
|
limit: { context: 200_000, output: 8_000 },
|
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
capabilities: {
|
|
toolcall: true,
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: true,
|
|
input: { text: true, image: false, audio: false, video: false },
|
|
output: { text: true, image: false, audio: false, video: false },
|
|
},
|
|
api: { npm: "@ai-sdk/anthropic" },
|
|
options: {},
|
|
} as never,
|
|
usage: stepFinish.usage!,
|
|
metadata: stepFinish.providerMetadata,
|
|
})
|
|
expect(result.tokens.cache.write).toBe(300)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
})
|
|
})
|
|
|
|
type Capture = {
|
|
url: URL
|
|
headers: Headers
|
|
body: Record<string, unknown>
|
|
}
|
|
|
|
const state = {
|
|
server: null as ReturnType<typeof Bun.serve> | null,
|
|
queue: [] as Array<{
|
|
path: string
|
|
response: Response | ((req: Request, capture: Capture) => Response)
|
|
resolve: (value: Capture) => void
|
|
}>,
|
|
}
|
|
|
|
function deferred<T>() {
|
|
const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
|
|
result.promise = new Promise((resolve) => {
|
|
result.resolve = resolve
|
|
})
|
|
return result
|
|
}
|
|
|
|
function waitRequest(pathname: string, response: Response) {
|
|
const pending = deferred<Capture>()
|
|
state.queue.push({ path: pathname, response, resolve: pending.resolve })
|
|
return pending.promise
|
|
}
|
|
|
|
function timeout(ms: number) {
|
|
return new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
|
|
})
|
|
}
|
|
|
|
function waitStreamingRequest(pathname: string) {
|
|
const request = deferred<Capture>()
|
|
const requestAborted = deferred<void>()
|
|
const responseCanceled = deferred<void>()
|
|
const encoder = new TextEncoder()
|
|
|
|
state.queue.push({
|
|
path: pathname,
|
|
resolve: request.resolve,
|
|
response(req: Request) {
|
|
req.signal.addEventListener("abort", () => requestAborted.resolve(), { once: true })
|
|
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
[
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl-abort",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ delta: { role: "assistant" } }],
|
|
})}`,
|
|
].join("\n\n") + "\n\n",
|
|
),
|
|
)
|
|
},
|
|
cancel() {
|
|
responseCanceled.resolve()
|
|
},
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
},
|
|
)
|
|
},
|
|
})
|
|
|
|
return {
|
|
request: request.promise,
|
|
requestAborted: requestAborted.promise,
|
|
responseCanceled: responseCanceled.promise,
|
|
}
|
|
}
|
|
|
|
beforeAll(() => {
|
|
state.server = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const next = state.queue.shift()
|
|
if (!next) {
|
|
return new Response("unexpected request", { status: 500 })
|
|
}
|
|
|
|
const url = new URL(req.url)
|
|
const body = (await req.json()) as Record<string, unknown>
|
|
next.resolve({ url, headers: req.headers, body })
|
|
|
|
if (!url.pathname.endsWith(next.path)) {
|
|
return new Response("not found", { status: 404 })
|
|
}
|
|
|
|
return typeof next.response === "function"
|
|
? next.response(req, { url, headers: req.headers, body })
|
|
: next.response
|
|
},
|
|
})
|
|
})
|
|
|
|
beforeEach(() => {
|
|
state.queue.length = 0
|
|
})
|
|
|
|
afterAll(() => {
|
|
void state.server?.stop()
|
|
})
|
|
|
|
function createChatStream(text: string) {
|
|
const payload =
|
|
[
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ delta: { role: "assistant" } }],
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ delta: { content: text } }],
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ delta: {}, finish_reason: "stop" }],
|
|
})}`,
|
|
"data: [DONE]",
|
|
].join("\n\n") + "\n\n"
|
|
|
|
const encoder = new TextEncoder()
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(payload))
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
const MODELS_FIXTURE = JSON.parse(
|
|
await Bun.file(path.join(import.meta.dir, "../tool/fixtures/models-api.json")).text(),
|
|
) as Record<string, ModelsDev.Provider>
|
|
|
|
function loadFixture(providerID: string, modelID: string) {
|
|
const provider = MODELS_FIXTURE[providerID]
|
|
if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
|
|
const model = provider.models[modelID]
|
|
if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
|
|
return { provider, model }
|
|
}
|
|
|
|
function configModel(model: ModelsDev.Model) {
|
|
return {
|
|
id: model.id,
|
|
name: model.name,
|
|
family: model.family,
|
|
release_date: model.release_date,
|
|
attachment: model.attachment,
|
|
reasoning: model.reasoning,
|
|
temperature: model.temperature,
|
|
tool_call: model.tool_call,
|
|
interleaved: model.interleaved,
|
|
cost: model.cost ? { ...model.cost, tiers: undefined } : undefined,
|
|
limit: model.limit,
|
|
modalities: model.modalities,
|
|
status: model.status,
|
|
provider: model.provider,
|
|
}
|
|
}
|
|
|
|
function createEventStream(chunks: unknown[], includeDone = false) {
|
|
const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
|
|
if (includeDone) {
|
|
lines.push("data: [DONE]")
|
|
}
|
|
const payload = lines.join("\n\n") + "\n\n"
|
|
const encoder = new TextEncoder()
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(payload))
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
function createEventResponse(chunks: unknown[], includeDone = false) {
|
|
return new Response(createEventStream(chunks, includeDone), {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
})
|
|
}
|
|
|
|
describe("session.llm.stream", () => {
|
|
const vivgridFixture = { providerID: "vivgrid", modelID: "gemini-3.1-pro-preview" }
|
|
it.instance(
|
|
"sends temperature, tokens, and reasoning options for openai-compatible models",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID)
|
|
const request = waitRequest(
|
|
"/chat/completions",
|
|
new Response(createChatStream("Hello"), {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
}),
|
|
)
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderID.make(vivgridFixture.providerID),
|
|
ModelID.make(fixture.model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-1")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
temperature: 0.4,
|
|
topP: 0.8,
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-1"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const body = capture.body
|
|
const headers = capture.headers
|
|
const url = capture.url
|
|
|
|
expect(url.pathname.startsWith("/v1/")).toBe(true)
|
|
expect(url.pathname.endsWith("/chat/completions")).toBe(true)
|
|
expect(headers.get("Authorization")).toBe("Bearer test-key")
|
|
|
|
expect(body.model).toBe(resolved.api.id)
|
|
expect(body.temperature).toBe(0.4)
|
|
expect(body.top_p).toBe(0.8)
|
|
expect(body.stream).toBe(true)
|
|
|
|
const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
|
|
const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
|
|
expect(maxTokens).toBe(expectedMaxTokens)
|
|
|
|
const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
|
|
expect(reasoning).toBe("high")
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [vivgridFixture.providerID],
|
|
provider: {
|
|
[vivgridFixture.providerID]: {
|
|
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
|
|
it.instance(
|
|
"service stream cancellation cancels provider response body promptly",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)
|
|
const pending = waitStreamingRequest("/chat/completions")
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderID.make(alibabaQwenFixture.providerID),
|
|
ModelID.make(fixture.model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-service-abort")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
const user = {
|
|
id: MessageID.make("msg_user-service-abort"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
|
|
} satisfies MessageV2.User
|
|
|
|
const fiber = yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
}).pipe(Effect.exit, Effect.forkScoped)
|
|
|
|
yield* Effect.promise(() => pending.request)
|
|
yield* Fiber.interrupt(fiber)
|
|
|
|
yield* Effect.promise(() => Promise.race([pending.responseCanceled, timeout(500)]))
|
|
const exit = yield* Fiber.await(fiber)
|
|
// Fiber.await returns an Exit<Exit<...>>. Unwrap once.
|
|
const inner = Exit.isSuccess(exit) ? exit.value : exit
|
|
expect(Exit.isFailure(inner)).toBe(true)
|
|
if (Exit.isFailure(inner)) {
|
|
expect(Cause.hasInterrupts(inner.cause)).toBe(true)
|
|
}
|
|
yield* Effect.promise(() => Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined))
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [alibabaQwenFixture.providerID],
|
|
provider: {
|
|
[alibabaQwenFixture.providerID]: {
|
|
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"keeps tools enabled by prompt permissions",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)
|
|
const request = waitRequest(
|
|
"/chat/completions",
|
|
new Response(createChatStream("Hello"), {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
}),
|
|
)
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderID.make(alibabaQwenFixture.providerID),
|
|
ModelID.make(fixture.model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-tools")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "question", pattern: "*", action: "deny" }],
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-tools"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
|
|
tools: { question: true },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
permission: [{ permission: "question", pattern: "*", action: "allow" }],
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {
|
|
question: tool({
|
|
description: "Ask a question",
|
|
inputSchema: z.object({}),
|
|
execute: async () => ({ output: "" }),
|
|
}),
|
|
},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
|
|
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [alibabaQwenFixture.providerID],
|
|
provider: {
|
|
[alibabaQwenFixture.providerID]: {
|
|
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"sends responses API payload for OpenAI models",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
|
|
const responseChunks = [
|
|
{
|
|
type: "response.created",
|
|
response: {
|
|
id: "resp-1",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
model: model.id,
|
|
service_tier: null,
|
|
},
|
|
},
|
|
{
|
|
type: "response.output_item.added",
|
|
output_index: 0,
|
|
item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
|
|
},
|
|
{
|
|
type: "response.content_part.added",
|
|
item_id: "item-1",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: "", annotations: [] },
|
|
},
|
|
{
|
|
type: "response.output_text.delta",
|
|
item_id: "item-1",
|
|
delta: "Hello",
|
|
logprobs: null,
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
incomplete_details: null,
|
|
usage: {
|
|
input_tokens: 1,
|
|
input_tokens_details: null,
|
|
output_tokens: 1,
|
|
output_tokens_details: null,
|
|
},
|
|
service_tier: null,
|
|
},
|
|
},
|
|
]
|
|
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-2")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
temperature: 0.2,
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-2"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const body = capture.body
|
|
|
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
|
expect(body.model).toBe(resolved.api.id)
|
|
expect(body.stream).toBe(true)
|
|
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
|
|
|
const maxTokens = body.max_output_tokens as number | undefined
|
|
expect(maxTokens).toBe(undefined) // match codex cli behavior
|
|
}),
|
|
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
|
|
)
|
|
|
|
it.instance(
|
|
"keeps supported OpenAI models on AI SDK path when native flag is off",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
const request = waitRequest(
|
|
"/responses",
|
|
createEventResponse(
|
|
[
|
|
{
|
|
type: "response.created",
|
|
response: {
|
|
id: "resp-flag-off",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
model: model.id,
|
|
service_tier: null,
|
|
},
|
|
},
|
|
{
|
|
type: "response.output_item.added",
|
|
output_index: 0,
|
|
item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] },
|
|
},
|
|
{
|
|
type: "response.content_part.added",
|
|
item_id: "item-flag-off",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: "", annotations: [] },
|
|
},
|
|
{
|
|
type: "response.output_text.delta",
|
|
item_id: "item-flag-off",
|
|
delta: "Flag off",
|
|
logprobs: null,
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
incomplete_details: null,
|
|
usage: {
|
|
input_tokens: 1,
|
|
input_tokens_details: null,
|
|
output_tokens: 1,
|
|
output_tokens_details: null,
|
|
},
|
|
service_tier: null,
|
|
},
|
|
},
|
|
],
|
|
true,
|
|
),
|
|
)
|
|
const failingNativeClient = Layer.succeed(
|
|
LLMClient.Service,
|
|
LLMClient.Service.of({
|
|
prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
|
stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")),
|
|
generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
|
}),
|
|
)
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-native-flag-off")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
|
|
yield* drainWith(
|
|
LLM.layer.pipe(
|
|
Layer.provide(Auth.defaultLayer),
|
|
Layer.provide(Config.defaultLayer),
|
|
Layer.provide(Provider.defaultLayer),
|
|
Layer.provide(Plugin.defaultLayer),
|
|
Layer.provide(failingNativeClient),
|
|
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })),
|
|
),
|
|
{
|
|
user: {
|
|
id: MessageID.make("msg_user-native-flag-off"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies MessageV2.User,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
},
|
|
)
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
|
expect(capture.body.model).toBe(resolved.api.id)
|
|
}),
|
|
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
|
|
)
|
|
|
|
it.instance(
|
|
"streams OpenAI through native runtime when opted in",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
const chunks = [
|
|
{ type: "response.created", response: { id: "resp-native" } },
|
|
{
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "item-native", status: "in_progress" },
|
|
},
|
|
{ type: "response.output_text.delta", item_id: "item-native", delta: "Hello native" },
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
incomplete_details: null,
|
|
usage: {
|
|
input_tokens: 1,
|
|
input_tokens_details: null,
|
|
output_tokens: 1,
|
|
output_tokens_details: null,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-native")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
temperature: 0.2,
|
|
} satisfies Agent.Info
|
|
|
|
yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), {
|
|
user: {
|
|
id: MessageID.make("msg_user-native"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies MessageV2.User,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
|
expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
|
|
expect(capture.body.model).toBe(model.id)
|
|
expect(capture.body.stream).toBe(true)
|
|
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
|
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
|
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
|
}),
|
|
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
|
|
)
|
|
|
|
it.instance(
|
|
"uses injected native request executor for tool calls",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
const chunks = [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
|
|
},
|
|
{
|
|
type: "response.function_call_arguments.delta",
|
|
item_id: "item-injected-tool",
|
|
delta: '{"query":"weather"}',
|
|
},
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "function_call",
|
|
id: "item-injected-tool",
|
|
call_id: "call-injected-tool",
|
|
name: "lookup",
|
|
arguments: '{"query":"weather"}',
|
|
},
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
|
},
|
|
]
|
|
let captured: Record<string, unknown> | undefined
|
|
let executed: unknown
|
|
const executor = Layer.succeed(
|
|
RequestExecutor.Service,
|
|
RequestExecutor.Service.of({
|
|
execute: (request) =>
|
|
Effect.gen(function* () {
|
|
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
|
captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
|
|
return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
|
|
}),
|
|
}),
|
|
)
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-native-injected-tool")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
|
|
yield* drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), {
|
|
user: {
|
|
id: MessageID.make("msg_user-native-injected-tool"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
|
} satisfies MessageV2.User,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: [],
|
|
messages: [{ role: "user", content: "Use lookup" }],
|
|
tools: {
|
|
lookup: tool({
|
|
description: "Lookup data",
|
|
inputSchema: z.object({ query: z.string() }),
|
|
execute: async (args, options) => {
|
|
executed = { args, toolCallId: options.toolCallId }
|
|
return { output: "looked up" }
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(captured?.model).toBe(model.id)
|
|
expect(captured?.tools).toEqual([
|
|
{
|
|
type: "function",
|
|
name: "lookup",
|
|
description: "Lookup data",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { query: { type: "string" } },
|
|
required: ["query"],
|
|
additionalProperties: false,
|
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
},
|
|
},
|
|
])
|
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
|
|
}),
|
|
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, "https://injected-openai.test/v1") },
|
|
)
|
|
|
|
it.instance(
|
|
"executes OpenAI tool calls through native runtime",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
const chunks = [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
|
|
},
|
|
{
|
|
type: "response.function_call_arguments.delta",
|
|
item_id: "item-native-tool",
|
|
delta: '{"query":"weather"}',
|
|
},
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "function_call",
|
|
id: "item-native-tool",
|
|
call_id: "call-native-tool",
|
|
name: "lookup",
|
|
arguments: '{"query":"weather"}',
|
|
},
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
|
},
|
|
]
|
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
|
let executed: unknown
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-native-tool")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
|
|
yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), {
|
|
user: {
|
|
id: MessageID.make("msg_user-native-tool"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
|
} satisfies MessageV2.User,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: [],
|
|
messages: [{ role: "user", content: "Use lookup" }],
|
|
tools: {
|
|
lookup: tool({
|
|
description: "Lookup data",
|
|
inputSchema: z.object({ query: z.string() }),
|
|
execute: async (args, options) => {
|
|
executed = { args, toolCallId: options.toolCallId }
|
|
return { output: "looked up" }
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
expect(capture.body.tools).toEqual([
|
|
{
|
|
type: "function",
|
|
name: "lookup",
|
|
description: "Lookup data",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { query: { type: "string" } },
|
|
required: ["query"],
|
|
additionalProperties: false,
|
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
},
|
|
},
|
|
])
|
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
|
|
}),
|
|
{
|
|
config: () => {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
return {
|
|
enabled_providers: ["openai"],
|
|
provider: {
|
|
openai: {
|
|
name: "OpenAI",
|
|
env: ["OPENAI_API_KEY"],
|
|
npm: "@ai-sdk/openai",
|
|
api: "https://api.openai.com/v1",
|
|
models: { [model.id]: JSON.parse(JSON.stringify(model)) as ConfigModel },
|
|
options: { apiKey: "test-openai-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"accepts user image attachments as data URLs for OpenAI models",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("openai", "gpt-5.2").model
|
|
const chunks = [
|
|
{
|
|
type: "response.created",
|
|
response: {
|
|
id: "resp-data-url",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
model: model.id,
|
|
service_tier: null,
|
|
},
|
|
},
|
|
{
|
|
type: "response.output_item.added",
|
|
output_index: 0,
|
|
item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
|
|
},
|
|
{
|
|
type: "response.content_part.added",
|
|
item_id: "item-data-url",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: "", annotations: [] },
|
|
},
|
|
{
|
|
type: "response.output_text.delta",
|
|
item_id: "item-data-url",
|
|
delta: "Looks good",
|
|
logprobs: null,
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
incomplete_details: null,
|
|
usage: {
|
|
input_tokens: 1,
|
|
input_tokens_details: null,
|
|
output_tokens: 1,
|
|
output_tokens_details: null,
|
|
},
|
|
service_tier: null,
|
|
},
|
|
},
|
|
]
|
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
|
const image = `data:image/png;base64,${Buffer.from(
|
|
yield* Effect.promise(() =>
|
|
Bun.file(path.join(import.meta.dir, "../tool/fixtures/large-image.png")).arrayBuffer(),
|
|
),
|
|
).toString("base64")}`
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-data-url")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-data-url"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Describe this image" },
|
|
{ type: "file", mediaType: "image/png", filename: "large-image.png", data: image },
|
|
],
|
|
},
|
|
] as ModelMessage[],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
|
}),
|
|
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
|
|
)
|
|
|
|
const minimaxFixture = { providerID: "minimax", modelID: "MiniMax-M2.5" }
|
|
it.instance(
|
|
"sends messages API payload for Anthropic Compatible models",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture(minimaxFixture.providerID, minimaxFixture.modelID).model
|
|
|
|
const chunks = [
|
|
{
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg-1",
|
|
model: model.id,
|
|
usage: {
|
|
input_tokens: 3,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "text", text: "" },
|
|
},
|
|
{
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "text_delta", text: "Hello" },
|
|
},
|
|
{ type: "content_block_stop", index: 0 },
|
|
{
|
|
type: "message_delta",
|
|
delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
|
|
usage: {
|
|
input_tokens: 3,
|
|
output_tokens: 2,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
},
|
|
},
|
|
{ type: "message_stop" },
|
|
]
|
|
const request = waitRequest("/messages", createEventResponse(chunks))
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderID.make(minimaxFixture.providerID),
|
|
ModelID.make(model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-3")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
temperature: 0.4,
|
|
topP: 0.9,
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-3"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const body = capture.body
|
|
|
|
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
|
|
expect(body.model).toBe(resolved.api.id)
|
|
expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
|
|
expect(body.temperature).toBe(0.4)
|
|
expect(body.top_p).toBe(0.9)
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [minimaxFixture.providerID],
|
|
provider: {
|
|
[minimaxFixture.providerID]: {
|
|
options: { apiKey: "test-anthropic-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"sends anthropic tool_use blocks with tool_result immediately after them",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture("anthropic", "claude-opus-4-6").model
|
|
const chunks = [
|
|
{
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg-tool-order",
|
|
model: model.id,
|
|
usage: {
|
|
input_tokens: 3,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "text", text: "" },
|
|
},
|
|
{
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "text_delta", text: "ok" },
|
|
},
|
|
{ type: "content_block_stop", index: 0 },
|
|
{
|
|
type: "message_delta",
|
|
delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
|
|
usage: {
|
|
input_tokens: 3,
|
|
output_tokens: 2,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
},
|
|
},
|
|
{ type: "message_stop" },
|
|
]
|
|
const request = waitRequest("/messages", createEventResponse(chunks))
|
|
|
|
const resolved = yield* Provider.use.getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
|
|
const sessionID = SessionID.make("session-test-anthropic-tools")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
const user = {
|
|
id: MessageID.make("msg_user-anthropic-tools"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
|
|
} satisfies MessageV2.User
|
|
|
|
const input = [
|
|
{
|
|
info: {
|
|
id: "msg_user",
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: 1 },
|
|
agent: "gentleman",
|
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "p_user",
|
|
sessionID,
|
|
messageID: "msg_user",
|
|
type: "text",
|
|
text: "Can you check whether there are any PDF files in my home directory?",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_call",
|
|
sessionID,
|
|
parentID: "msg_user",
|
|
role: "assistant",
|
|
mode: "gentleman",
|
|
agent: "gentleman",
|
|
variant: "max",
|
|
path: { cwd: "/root", root: "/" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
modelID: "claude-opus-4-6",
|
|
providerID: "anthropic",
|
|
time: { created: 2, completed: 3 },
|
|
finish: "tool-calls",
|
|
},
|
|
parts: [
|
|
{
|
|
id: "p_step",
|
|
sessionID,
|
|
messageID: "msg_call",
|
|
type: "step-start",
|
|
},
|
|
{
|
|
id: "p_read",
|
|
sessionID,
|
|
messageID: "msg_call",
|
|
type: "tool",
|
|
tool: "read",
|
|
callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
|
state: {
|
|
status: "completed",
|
|
input: { filePath: "/root" },
|
|
output: "<path>/root</path>",
|
|
metadata: {},
|
|
title: "root",
|
|
time: { start: 10, end: 11 },
|
|
},
|
|
},
|
|
{
|
|
id: "p_glob",
|
|
sessionID,
|
|
messageID: "msg_call",
|
|
type: "tool",
|
|
tool: "glob",
|
|
callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
|
state: {
|
|
status: "completed",
|
|
input: { pattern: "**/*.pdf", path: "/root" },
|
|
output: "No files found",
|
|
metadata: {},
|
|
title: "root",
|
|
time: { start: 12, end: 13 },
|
|
},
|
|
},
|
|
{
|
|
id: "p_text",
|
|
sessionID,
|
|
messageID: "msg_call",
|
|
type: "text",
|
|
text: "I checked your home directory and looked for PDF files.",
|
|
time: { start: 14, end: 15 },
|
|
},
|
|
],
|
|
},
|
|
] as any[]
|
|
|
|
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(input as any, resolved))
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: [],
|
|
messages: modelMessages,
|
|
tools: {
|
|
read: tool({
|
|
description: "Stub read tool",
|
|
inputSchema: z.object({ filePath: z.string() }),
|
|
execute: async () => ({ output: "stub" }),
|
|
}),
|
|
glob: tool({
|
|
description: "Stub glob tool",
|
|
inputSchema: z.object({ pattern: z.string(), path: z.string().optional() }),
|
|
execute: async () => ({ output: "stub" }),
|
|
}),
|
|
},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const body = capture.body
|
|
|
|
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
|
|
const messages = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
|
expect(messages[0]?.role).toBe("user")
|
|
expect(messages[0]?.content[0]).toMatchObject({
|
|
type: "text",
|
|
text: "Can you check whether there are any PDF files in my home directory?",
|
|
})
|
|
expect(messages.some((message) => message.content.some((part) => "cache_control" in part))).toBe(true)
|
|
const toolUseIndex = messages.findIndex((message) => message.content.some((part) => part.type === "tool_use"))
|
|
expect(toolUseIndex).toBeGreaterThan(0)
|
|
expect(messages[toolUseIndex].role).toBe("assistant")
|
|
expect(messages[toolUseIndex].content.filter((part) => part.type === "tool_use")).toMatchObject([
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
|
name: "read",
|
|
input: { filePath: "/root" },
|
|
},
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
|
name: "glob",
|
|
input: { pattern: "**/*.pdf", path: "/root" },
|
|
},
|
|
])
|
|
expect(messages[toolUseIndex + 1]).toMatchObject({
|
|
role: "user",
|
|
content: [
|
|
{ type: "tool_result", tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT", content: "<path>/root</path>" },
|
|
{ type: "tool_result", tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr", content: "No files found" },
|
|
],
|
|
})
|
|
}),
|
|
{
|
|
config: () => {
|
|
const model = loadFixture("anthropic", "claude-opus-4-6").model
|
|
return {
|
|
enabled_providers: ["anthropic"],
|
|
provider: {
|
|
anthropic: {
|
|
name: "Anthropic",
|
|
env: ["ANTHROPIC_API_KEY"],
|
|
npm: "@ai-sdk/anthropic",
|
|
api: "https://api.anthropic.com/v1",
|
|
models: { [model.id]: configModel(model) as ConfigModel },
|
|
options: { apiKey: "test-anthropic-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
const geminiFixture = { providerID: "google", modelID: "gemini-2.5-flash" }
|
|
it.instance(
|
|
"sends Google API payload for Gemini models",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const model = loadFixture(geminiFixture.providerID, geminiFixture.modelID).model
|
|
const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
|
|
|
|
const chunks = [
|
|
{
|
|
candidates: [
|
|
{ content: { parts: [{ text: "Hello" }] }, finishReason: "STOP" },
|
|
],
|
|
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
|
},
|
|
]
|
|
const request = waitRequest(pathSuffix, createEventResponse(chunks))
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderID.make(geminiFixture.providerID),
|
|
ModelID.make(model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-4")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
temperature: 0.3,
|
|
topP: 0.8,
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-4"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderID.make(geminiFixture.providerID), modelID: resolved.id },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: {},
|
|
})
|
|
|
|
const capture = yield* Effect.promise(() => request)
|
|
const body = capture.body
|
|
const config = body.generationConfig as
|
|
| { temperature?: number; topP?: number; maxOutputTokens?: number }
|
|
| undefined
|
|
|
|
expect(capture.url.pathname).toBe(pathSuffix)
|
|
expect(config?.temperature).toBe(0.3)
|
|
expect(config?.topP).toBe(0.8)
|
|
expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [geminiFixture.providerID],
|
|
provider: {
|
|
[geminiFixture.providerID]: {
|
|
options: { apiKey: "test-google-key", baseURL: `${state.server!.url.origin}/v1beta` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
})
|