mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
2072 lines
72 KiB
TypeScript
2072 lines
72 KiB
TypeScript
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
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 { 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"
|
|
import { USER_AGENT } from "../../src/installation" // kilocode_change
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
|
|
type ConfigModel = NonNullable<NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]>["models"]>[string] // kilocode_change
|
|
|
|
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<ConfigV1.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 PermissionV1.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" },
|
|
])
|
|
})
|
|
|
|
// kilocode_change start - preserve AI SDK raw usage for Kilo provider billing
|
|
test("preserves raw usage in native usage provider metadata", async () => {
|
|
const events = await adapt([
|
|
uncheckedAdapterEvent({
|
|
type: "finish-step",
|
|
finishReason: "stop",
|
|
usage: {
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
totalTokens: 15,
|
|
raw: {
|
|
cost: 0.01,
|
|
cost_details: { upstream_inference_cost: 0.02 },
|
|
},
|
|
},
|
|
}),
|
|
])
|
|
|
|
const step = events[0]
|
|
if (step.type !== "step-finish") throw new Error("expected step-finish")
|
|
expect(step.usage?.providerMetadata).toEqual({
|
|
aiSdk: {
|
|
cost: 0.01,
|
|
cost_details: { upstream_inference_cost: 0.02 },
|
|
},
|
|
})
|
|
})
|
|
// kilocode_change end
|
|
|
|
// 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")
|
|
// kilocode_change start
|
|
expect(stepFinish.providerMetadata).toEqual({
|
|
anthropic: { cacheCreationInputTokens: 300 },
|
|
kilocode: { routedModelID: "claude-3-5-sonnet" },
|
|
})
|
|
// kilocode_change end
|
|
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)
|
|
})
|
|
|
|
test("captures Copilot billed usage from raw Anthropic message deltas per step", async () => {
|
|
const events = await adapt([
|
|
uncheckedAdapterEvent({
|
|
type: "raw",
|
|
rawValue: {
|
|
type: "message_delta",
|
|
copilot_usage: { total_nano_aiu: 4_473_525_000 },
|
|
},
|
|
}),
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-sonnet-4.6" },
|
|
finishReason: "stop",
|
|
rawFinishReason: "end_turn",
|
|
usage: {
|
|
inputTokens: 11_774,
|
|
outputTokens: 39,
|
|
totalTokens: 11_813,
|
|
inputTokenDetails: { noCacheTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 11_771 },
|
|
outputTokenDetails: { textTokens: 39, reasoningTokens: undefined },
|
|
},
|
|
providerMetadata: { anthropic: { cacheCreationInputTokens: 11_771 } },
|
|
},
|
|
{
|
|
type: "finish-step",
|
|
response: { id: "msg_follow_up", timestamp: new Date(0), modelId: "claude-sonnet-4.6" },
|
|
finishReason: "stop",
|
|
rawFinishReason: "end_turn",
|
|
usage: {
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
totalTokens: 2,
|
|
inputTokenDetails: { noCacheTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
outputTokenDetails: { textTokens: 1, reasoningTokens: undefined },
|
|
},
|
|
providerMetadata: { anthropic: {} },
|
|
},
|
|
])
|
|
|
|
expect(events[0]).toMatchObject({
|
|
type: "step-finish",
|
|
providerMetadata: {
|
|
anthropic: { cacheCreationInputTokens: 11_771 },
|
|
copilot: { totalNanoAiu: 4_473_525_000 },
|
|
},
|
|
})
|
|
expect(events[1]).toMatchObject({ type: "step-finish", providerMetadata: { anthropic: {} } })
|
|
if (events[1].type !== "step-finish") throw new Error("expected step-finish")
|
|
expect(events[1].providerMetadata?.copilot).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
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(
|
|
ProviderV2.ID.make(vivgridFixture.providerID),
|
|
ModelV2.ID.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: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" },
|
|
} satisfies SessionV1.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(headers.get("User-Agent") ?? "").toMatch(/^Kilo-Code\//) // kilocode_change
|
|
|
|
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(
|
|
ProviderV2.ID.make(alibabaQwenFixture.providerID),
|
|
ModelV2.ID.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: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
|
|
} satisfies SessionV1.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(
|
|
ProviderV2.ID.make(alibabaQwenFixture.providerID),
|
|
ModelV2.ID.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: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
|
|
tools: { question: true },
|
|
} satisfies SessionV1.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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies SessionV1.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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies SessionV1.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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
|
|
} satisfies SessionV1.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(capture.body.include).toEqual(["reasoning.encrypted_content"])
|
|
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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id },
|
|
} satisfies SessionV1.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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id },
|
|
} satisfies SessionV1.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(ProviderV2.ID.openai, ModelV2.ID.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: ProviderV2.ID.make("openai"), modelID: resolved.id },
|
|
} satisfies SessionV1.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(
|
|
ProviderV2.ID.make(minimaxFixture.providerID),
|
|
ModelV2.ID.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: ProviderV2.ID.make("minimax"), modelID: ModelV2.ID.make("MiniMax-M2.5") },
|
|
} satisfies SessionV1.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(ProviderV2.ID.make("anthropic"), ModelV2.ID.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: ProviderV2.ID.make("anthropic"), modelID: resolved.id, variant: "max" },
|
|
} satisfies SessionV1.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
|
|
const headers = capture.headers // kilocode_change
|
|
|
|
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
|
|
expect(headers.get("User-Agent")?.split(" ")[0]).toBe(USER_AGENT) // kilocode_change
|
|
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(
|
|
ProviderV2.ID.make(geminiFixture.providerID),
|
|
ModelV2.ID.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: ProviderV2.ID.make(geminiFixture.providerID), modelID: resolved.id },
|
|
} satisfies SessionV1.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "Hello" },
|
|
{ role: "assistant", content: [{ type: "reasoning", text: "" }] },
|
|
],
|
|
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(body.contents).toEqual([{ role: "user", parts: [{ text: "Hello" }] }])
|
|
// kilocode_change start - auth keys use the same Google API key header as Standard keys
|
|
expect(capture.headers.get("x-goog-api-key")).toBe("test-google-key")
|
|
expect(capture.headers.get("authorization")).toBeNull()
|
|
expect(capture.url.searchParams.get("key")).toBeNull()
|
|
// kilocode_change end
|
|
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` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance(
|
|
"repairs whitespace-padded tool names and executes the correct tool",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)
|
|
let toolExecuted = false
|
|
|
|
waitRequest(
|
|
"/chat/completions",
|
|
createEventResponse(
|
|
[
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: " bash", arguments: "" } }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] } }],
|
|
},
|
|
{
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
},
|
|
],
|
|
true,
|
|
),
|
|
)
|
|
|
|
const resolved = yield* Provider.use.getModel(
|
|
ProviderV2.ID.make(alibabaQwenFixture.providerID),
|
|
ModelV2.ID.make(fixture.model.id),
|
|
)
|
|
const sessionID = SessionID.make("session-test-repair")
|
|
const agent = {
|
|
name: "test",
|
|
mode: "primary",
|
|
options: {},
|
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
} satisfies Agent.Info
|
|
|
|
const user = {
|
|
id: MessageID.make("msg_user-repair"),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: agent.name,
|
|
model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
|
|
} satisfies MessageV2.User
|
|
|
|
yield* drain({
|
|
user,
|
|
sessionID,
|
|
model: resolved,
|
|
agent,
|
|
system: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Run bash" }],
|
|
tools: {
|
|
bash: tool({
|
|
description: "Run bash",
|
|
inputSchema: z.object({}),
|
|
execute: async () => {
|
|
toolExecuted = true
|
|
return { output: "" }
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(toolExecuted).toBe(true)
|
|
}),
|
|
{
|
|
config: () => ({
|
|
enabled_providers: [alibabaQwenFixture.providerID],
|
|
provider: {
|
|
[alibabaQwenFixture.providerID]: {
|
|
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
// kilocode_change end
|
|
})
|