2026-06-03 02:42:13 +00:00
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
2026-05-21 03:29:25 +00:00
import { ModelsDev } from "@opencode-ai/core/models-dev"
2026-06-06 03:01:26 +00:00
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
2026-05-21 03:29:25 +00:00
import { describe , expect , test } from "bun:test"
2026-05-19 12:20:27 +00:00
import { tool , type ModelMessage , type JSONValue } from "ai"
2026-05-21 03:29:25 +00:00
import { Effect , Layer , Option , Schema , Stream } from "effect"
2026-05-18 18:41:36 +00:00
import path from "node:path"
import z from "zod"
import { Auth } from "@/auth"
import { Provider } from "@/provider/provider"
2026-05-31 01:08:38 +00:00
2026-05-18 18:41:36 +00:00
import { Filesystem } from "@/util/filesystem"
2026-05-19 12:20:27 +00:00
import { LLMEvent , LLMResponse } from "@opencode-ai/llm"
2026-06-29 19:55:58 +00:00
import { RequestExecutor } from "@opencode-ai/llm/route"
2026-05-18 18:41:36 +00:00
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm"
import { MessageID , SessionID } from "../../src/session/schema"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
2026-05-31 01:08:38 +00:00
import { ProviderV2 } from "@opencode-ai/core/provider"
2026-06-04 06:57:43 +00:00
import { ModelV2 } from "@opencode-ai/core/model"
2026-06-29 19:55:58 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
2026-05-18 18:41:36 +00:00
const FIXTURES_DIR = path . join ( import . meta . dir , "../fixtures/recordings" )
2026-05-19 12:20:27 +00:00
const zenURL = ( connection : string ) = > ` https://console.opencode.ai/proxy/connections/ ${ connection } /v1 `
2026-05-21 03:29:25 +00:00
const replayOpenAIOAuth = {
type : "oauth" ,
refresh : "fixture-refresh-token" ,
access : "fixture-access-token" ,
expires : Date.now ( ) + 60 * 60 * 1000 ,
accountId : "fixture-account" ,
} satisfies Auth . Info
type RecordedScenario = {
readonly id : string
readonly name : string
2026-05-31 01:08:38 +00:00
readonly providerID : ProviderV2.ID
2026-05-19 12:20:27 +00:00
readonly modelID : string
readonly cassette : string
readonly protocol : string
readonly tags : ReadonlyArray < string >
2026-05-21 03:29:25 +00:00
readonly canRecord : ( ) = > boolean
readonly recordAuth ? : ( ) = > Auth . Info | undefined
readonly replayAuth? : Auth.Info
readonly stableID? : string
2026-06-03 02:42:13 +00:00
readonly config : ( model : ModelsDev.Provider [ "models" ] [ string ] ) = > Partial < ConfigV1.Info >
2026-05-19 12:20:27 +00:00
}
2026-05-21 03:29:25 +00:00
const cloneModel = ( model : ModelsDev.Provider [ "models" ] [ string ] ) = > {
const cloned = structuredClone ( model )
const { experimental , . . . rest } = cloned
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The config schema accepts the same model shape except object-valued experimental metadata.
2026-06-06 03:01:26 +00:00
if ( typeof experimental === "boolean" ) {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The fixture model already matches config input when experimental is boolean.
2026-06-03 02:42:13 +00:00
return cloned as NonNullable < NonNullable < ConfigV1.Info [ " provider " ] > [ string ] [ "models" ] > [ string ]
2026-06-06 03:01:26 +00:00
}
2026-05-21 03:29:25 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Dropping non-boolean experimental metadata makes the fixture model match config input.
2026-06-03 02:42:13 +00:00
return rest as NonNullable < NonNullable < ConfigV1.Info [ " provider " ] > [ string ] [ "models" ] > [ string ]
2026-05-21 03:29:25 +00:00
}
const envValue = ( . . . names : string [ ] ) = > names . map ( ( name ) = > process . env [ name ] ) . find ( Boolean )
const decodeAuth = Schema . decodeUnknownOption ( Auth . Info )
const recordOpenAIOAuth = ( ( ) = > {
let loaded = false
let auth : Auth.Info | undefined
return ( ) = > {
if ( loaded ) return auth
loaded = true
auth = decodeRecordOpenAIOAuth ( )
return auth
}
} ) ( )
function decodeRecordOpenAIOAuth() {
const value = process . env . OPENCODE_RECORD_OPENAI_AUTH
if ( ! value ) return undefined
try {
const auth = Option . getOrUndefined ( decodeAuth ( JSON . parse ( value ) ) )
return auth ? . type === "oauth" ? auth : undefined
} catch {
return undefined
}
}
const providerConfig = ( input : {
2026-05-31 01:08:38 +00:00
readonly providerID : ProviderV2.ID
2026-05-21 03:29:25 +00:00
readonly name : string
readonly env : string [ ]
readonly npm : string
readonly api : string
readonly model : ModelsDev.Provider [ "models" ] [ string ]
readonly options : Record < string , unknown >
2026-06-03 02:42:13 +00:00
} ) : Partial < ConfigV1.Info > = > ( {
2026-05-21 03:29:25 +00:00
enabled_providers : [ input . providerID ] ,
provider : {
[ input . providerID ] : {
name : input.name ,
env : input.env ,
npm : input.npm ,
api : input.api ,
models : { [ input . model . id ] : cloneModel ( input . model ) } ,
options : input.options ,
} ,
} ,
} )
2026-05-19 12:20:27 +00:00
2026-05-21 03:29:25 +00:00
const RECORDED_SCENARIOS = [
{
id : "openai-api-key" ,
name : "OpenAI API key" ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.openai ,
2026-05-19 12:20:27 +00:00
modelID : "gpt-4.1-mini" ,
cassette : "session/native-openai-tool-loop" ,
protocol : "openai-responses" ,
tags : [ "opencode" , "native" , "tool-loop" ] ,
2026-05-21 03:29:25 +00:00
canRecord : ( ) = > Boolean ( envValue ( "OPENCODE_RECORD_OPENAI_API_KEY" , "OPENAI_API_KEY" ) ) ,
config : ( model ) = >
providerConfig ( {
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.openai ,
2026-05-21 03:29:25 +00:00
name : "OpenAI" ,
env : [ "OPENAI_API_KEY" ] ,
npm : "@ai-sdk/openai" ,
api : "https://api.openai.com/v1" ,
model ,
options : {
apiKey : envValue ( "OPENCODE_RECORD_OPENAI_API_KEY" , "OPENAI_API_KEY" ) ? ? "fixture-openai-key" ,
baseURL : "https://api.openai.com/v1" ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
} ) ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
{
id : "openai-oauth" ,
name : "OpenAI OAuth" ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.openai ,
2026-05-21 03:29:25 +00:00
modelID : "gpt-5.5" ,
cassette : "session/native-openai-oauth-tool-loop" ,
protocol : "openai-responses" ,
tags : [ "opencode" , "native" , "oauth" , "tool-loop" ] ,
canRecord : ( ) = > recordOpenAIOAuth ( ) !== undefined ,
recordAuth : recordOpenAIOAuth ,
replayAuth : replayOpenAIOAuth ,
stableID : "openai-oauth" ,
config : ( model ) = >
providerConfig ( {
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.openai ,
2026-05-21 03:29:25 +00:00
name : "OpenAI" ,
env : [ "OPENAI_API_KEY" ] ,
npm : "@ai-sdk/openai" ,
api : "https://api.openai.com/v1" ,
model ,
options : { baseURL : "https://api.openai.com/v1" } ,
} ) ,
} ,
{
id : "opencode-proxy" ,
name : "OpenCode proxy" ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.opencode ,
2026-05-19 12:20:27 +00:00
modelID : "gpt-5.2-codex" ,
cassette : "session/native-zen-tool-loop" ,
protocol : "openai-responses" ,
tags : [ "opencode" , "zen" , "native" , "tool-loop" ] ,
2026-05-21 03:29:25 +00:00
canRecord : ( ) = > Boolean ( process . env . OPENCODE_RECORD_CONSOLE_TOKEN && process . env . OPENCODE_RECORD_ZEN_ORG_ID ) ,
config : ( model ) = >
providerConfig ( {
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.opencode ,
2026-05-21 03:29:25 +00:00
name : "OpenCode Zen" ,
env : [ "OPENCODE_CONSOLE_TOKEN" ] ,
npm : "@ai-sdk/openai-compatible" ,
api : zenURL ( process . env . OPENCODE_RECORD_ZEN_CONNECTION ? ? "fixture" ) ,
model ,
options : {
apiKey : process.env.OPENCODE_RECORD_CONSOLE_TOKEN ? ? "fixture-console-token" ,
headers : { "x-org-id" : process . env . OPENCODE_RECORD_ZEN_ORG_ID ? ? "fixture-org" } ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
} ) ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
{
id : "anthropic-api-key" ,
name : "Anthropic API key" ,
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.anthropic ,
2026-05-19 12:20:27 +00:00
modelID : "claude-haiku-4-5-20251001" ,
cassette : "session/native-anthropic-tool-loop" ,
protocol : "anthropic-messages" ,
tags : [ "opencode" , "native" , "tool-loop" ] ,
2026-05-21 03:29:25 +00:00
canRecord : ( ) = > Boolean ( envValue ( "OPENCODE_RECORD_ANTHROPIC_API_KEY" , "ANTHROPIC_API_KEY" ) ) ,
config : ( model ) = >
providerConfig ( {
2026-05-31 01:08:38 +00:00
providerID : ProviderV2.ID.anthropic ,
2026-05-21 03:29:25 +00:00
name : "Anthropic" ,
env : [ "ANTHROPIC_API_KEY" ] ,
npm : "@ai-sdk/anthropic" ,
api : "https://api.anthropic.com/v1" ,
model ,
options : {
apiKey : envValue ( "OPENCODE_RECORD_ANTHROPIC_API_KEY" , "ANTHROPIC_API_KEY" ) ? ? "fixture-anthropic-key" ,
baseURL : "https://api.anthropic.com/v1" ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
} ) ,
2026-05-19 12:20:27 +00:00
} ,
2026-05-21 03:29:25 +00:00
] satisfies ReadonlyArray < RecordedScenario >
2026-05-18 18:41:36 +00:00
const shouldRecord = process . env . RECORD === "true"
2026-05-21 03:29:25 +00:00
const selectedScenarios = new Set (
( envValue ( "OPENCODE_RECORDED_SCENARIO" , "RECORDED_PROVIDER" ) ? ? "" )
. split ( "," )
. map ( ( item ) = > item . trim ( ) . toLowerCase ( ) )
. filter ( Boolean ) ,
)
2026-05-19 12:20:27 +00:00
2026-05-21 03:29:25 +00:00
function isSelected ( scenario : RecordedScenario ) {
if ( selectedScenarios . size === 0 ) return true
return [ scenario . id , scenario . name , scenario . providerID , scenario . cassette , . . . scenario . tags ]
. map ( ( item ) = > item . toLowerCase ( ) )
. some ( ( item ) = > selectedScenarios . has ( item ) )
}
const canRun = ( scenario : RecordedScenario ) = >
2026-06-06 03:01:26 +00:00
shouldRecord
? scenario . canRecord ( )
: HttpRecorderInternal . hasCassetteSync ( scenario . cassette , { directory : FIXTURES_DIR } )
2026-05-21 03:29:25 +00:00
const recordError = ( scenario : RecordedScenario ) = >
scenario . id === "openai-oauth"
? "Set OPENCODE_RECORD_OPENAI_AUTH to an OAuth auth JSON object in the recording environment."
: ` Missing recording credentials for ${ scenario . name } . `
const redactRecordedBody = ( body : string ) = >
body
. replace ( /wrk_[A-Z0-9]+/g , "wrk_redacted" )
. replace ( /"safety_identifier"\s*:\s*"user-[^"]+"/g , '"safety_identifier":"user_redacted"' )
. replace ( /"(access|access_token|refresh|refresh_token|accountId|account_id)"\s*:\s*"[^"]+"/g , '"$1":"redacted"' )
function authLayer ( scenario : RecordedScenario ) {
const replayAuth = shouldRecord ? scenario . recordAuth ? . ( ) : scenario . replayAuth
2026-06-29 19:55:58 +00:00
if ( ! replayAuth ) return undefined
2026-05-21 03:29:25 +00:00
return Layer . mock ( Auth . Service ) ( {
get : ( providerID ) = > Effect . succeed ( providerID === scenario . providerID ? replayAuth : undefined ) ,
all : ( ) = > Effect . succeed ( { [ scenario . providerID ] : replayAuth } ) ,
} )
}
2026-05-18 18:41:36 +00:00
async function loadFixture ( providerID : string , modelID : string ) {
2026-05-21 03:29:25 +00:00
const data = await modelsFixture
2026-05-18 18:41:36 +00:00
const provider = data [ 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 model
}
2026-05-21 03:29:25 +00:00
const modelsFixture = Filesystem . readJson < Record < string , ModelsDev.Provider > > (
path . join ( import . meta . dir , "../tool/fixtures/models-api.json" ) ,
)
function recordedNativeLLMLayer ( scenario : RecordedScenario ) {
const auth = authLayer ( scenario )
2026-05-18 18:41:36 +00:00
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
2026-06-06 03:01:26 +00:00
const metadata = {
provider : scenario.providerID ,
protocol : scenario.protocol ,
route : scenario.protocol ,
tags : scenario.tags ,
}
const redact = {
url : ( url : string ) = > url . replace ( /\/proxy\/connections\/[^/]+\/v1/ , "/proxy/connections/{connection}/v1" ) ,
body : redactRecordedBody ,
}
const recordedHttp = shouldRecord
? HttpRecorderInternal . cassetteLayer ( scenario . cassette , {
directory : FIXTURES_DIR ,
mode : "record" ,
metadata ,
redactor : HttpRecorderInternal.Redactor.make ( redact ) ,
} )
: HttpRecorder . http ( scenario . cassette , { directory : FIXTURES_DIR , metadata , redact } )
2026-06-29 19:55:58 +00:00
return AppNodeBuilder . build ( LayerNode . group ( [ Provider . node , LLM . node ] ) , [
[ LayerNodePlatform . requestExecutor , RequestExecutor . layer . pipe ( Layer . provide ( recordedHttp ) ) ] ,
[ RuntimeFlags . node , RuntimeFlags . layer ( { experimentalNativeLlm : true } ) ] ,
. . . ( auth ? ( [ [ Auth . node , auth ] ] as const ) : [ ] ) ,
] )
2026-05-18 18:41:36 +00:00
}
2026-05-21 03:29:25 +00:00
const writeConfig = ( directory : string , scenario : RecordedScenario , model : ModelsDev.Provider [ "models" ] [ string ] ) = >
2026-05-18 18:41:36 +00:00
Effect . promise ( ( ) = >
Bun . write (
path . join ( directory , "opencode.json" ) ,
2026-05-21 03:29:25 +00:00
JSON . stringify ( { $schema : "https://opencode.ai/config.json" , . . . scenario . config ( model ) } ) ,
2026-05-18 18:41:36 +00:00
) ,
)
const collect = ( input : LLM.StreamInput ) = >
Effect . gen ( function * ( ) {
const llm = yield * LLM . Service
return Array . from ( yield * llm . stream ( input ) . pipe ( Stream . runCollect ) )
} )
2026-05-19 12:20:27 +00:00
const WEATHER_RESULT = { temperature : 22 , condition : "sunny" } as const
const WEATHER_SYSTEM =
"Use the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny."
const WEATHER_USER = "What is the weather in Paris?"
2026-05-18 18:41:36 +00:00
2026-05-19 12:20:27 +00:00
const weatherTool = tool ( {
description : "Get the current weather for a city." ,
inputSchema : z.object ( { city : z.string ( ) } ) ,
execute : async ( ) = > WEATHER_RESULT ,
} )
2026-05-18 18:41:36 +00:00
2026-05-19 12:20:27 +00:00
const toolRoundtrip = (
2026-05-24 01:06:45 +00:00
events : ReadonlyArray < LLMEvent > ,
2026-05-19 12:20:27 +00:00
call : { readonly id : string ; readonly name : string ; readonly input : unknown } ,
result : JSONValue ,
) : ModelMessage [ ] = > [
2026-05-24 01:06:45 +00:00
{
role : "assistant" ,
content : [
. . . events . filter ( LLMEvent . is . reasoningEnd ) . map ( ( part ) = > ( {
type : "reasoning" as const ,
text : events
. filter ( LLMEvent . is . reasoningDelta )
. filter ( ( event ) = > event . id === part . id )
. map ( ( event ) = > event . text )
. join ( "" ) ,
providerMetadata : part.providerMetadata ,
} ) ) ,
{ type : "tool-call" , toolCallId : call.id , toolName : call.name , input : call.input } ,
] ,
} ,
2026-05-19 12:20:27 +00:00
{
role : "tool" ,
2026-05-19 12:21:56 +00:00
content : [
{ type : "tool-result" , toolCallId : call.id , toolName : call.name , output : { type : "json" , value : result } } ,
] ,
2026-05-19 12:20:27 +00:00
} ,
]
2026-05-18 18:41:36 +00:00
2026-05-21 03:29:25 +00:00
const driveToolLoop = ( scenario : RecordedScenario ) = >
2026-05-19 12:20:27 +00:00
Effect . gen ( function * ( ) {
const test = yield * TestInstance
2026-05-21 03:29:25 +00:00
const model = yield * Effect . promise ( ( ) = > loadFixture ( scenario . providerID , scenario . modelID ) )
yield * writeConfig ( test . directory , scenario , model )
2026-05-18 18:41:36 +00:00
2026-05-21 03:29:25 +00:00
const stableID = scenario . stableID ? ? scenario . providerID
const sessionID = SessionID . make ( ` session-recorded- ${ stableID } -loop ` )
2026-06-04 06:57:43 +00:00
const modelID = ModelV2 . ID . make ( model . id )
2026-05-19 12:20:27 +00:00
const agent = {
name : "test" ,
mode : "primary" ,
prompt : "Answer using tools when appropriate." ,
options : { } ,
permission : [ { permission : "*" , pattern : "*" , action : "allow" } ] ,
temperature : 0 ,
} satisfies Agent . Info
const provider = yield * Provider . Service
2026-05-21 03:29:25 +00:00
const resolved = yield * provider . getModel ( scenario . providerID , modelID )
2026-05-18 18:41:36 +00:00
2026-05-19 12:20:27 +00:00
const userMessage = { role : "user" , content : WEATHER_USER } satisfies ModelMessage
const base = {
user : {
2026-05-21 03:29:25 +00:00
id : MessageID.make ( ` msg_user-recorded- ${ stableID } -loop ` ) ,
2026-05-18 18:41:36 +00:00
sessionID ,
2026-05-19 12:20:27 +00:00
role : "user" ,
time : { created : 0 } ,
agent : agent.name ,
2026-05-21 03:29:25 +00:00
model : { providerID : scenario.providerID , modelID } ,
2026-06-03 02:42:13 +00:00
} satisfies SessionV1 . User ,
2026-05-19 12:20:27 +00:00
sessionID ,
model : resolved ,
agent ,
system : [ WEATHER_SYSTEM ] ,
tools : { get_weather : weatherTool } ,
}
2026-05-18 18:41:36 +00:00
2026-05-19 12:20:27 +00:00
const turn1 = yield * collect ( { . . . base , messages : [ userMessage ] } )
const toolCall = turn1 . find ( LLMEvent . is . toolCall )
expect ( toolCall ) . toBeDefined ( )
expect ( turn1 . find ( LLMEvent . is . toolResult ) ) . toBeDefined ( )
expect ( toolCall ! . name ) . toBe ( "get_weather" )
expect ( toolCall ! . input ) . toMatchObject ( { city : expect.stringMatching ( /Paris/i ) } )
expect ( turn1 . filter ( LLMEvent . is . stepFinish ) ) . toHaveLength ( 1 )
const turn2 = yield * collect ( {
. . . base ,
2026-05-24 01:06:45 +00:00
messages : [ userMessage , . . . toolRoundtrip ( turn1 , toolCall ! , WEATHER_RESULT ) ] ,
2026-05-19 12:20:27 +00:00
} )
expect ( LLMResponse . text ( { events : turn2 } ) ) . toMatch ( /Paris is sunny/i )
expect ( turn2 . filter ( LLMEvent . is . finish ) ) . toHaveLength ( 1 )
expect ( turn2 . filter ( LLMEvent . is . toolCall ) ) . toHaveLength ( 0 )
} )
describe ( "session.llm native recorded" , ( ) = > {
2026-05-21 03:29:25 +00:00
for ( const scenario of RECORDED_SCENARIOS . filter ( isSelected ) ) {
if ( ! canRun ( scenario ) ) {
if ( shouldRecord && scenario . recordAuth && selectedScenarios . size > 0 ) {
test ( ` ${ scenario . name } : drives a tool loop to a final text answer ` , ( ) = > {
throw new Error ( recordError ( scenario ) )
} )
continue
}
test . skip ( ` ${ scenario . name } : drives a tool loop to a final text answer ` , ( ) = > { } )
continue
}
const it = testEffect ( recordedNativeLLMLayer ( scenario ) )
it . instance ( ` ${ scenario . name } : drives a tool loop to a final text answer ` , ( ) = > driveToolLoop ( scenario ) )
2026-05-19 12:20:27 +00:00
}
2026-05-18 18:41:36 +00:00
} )