chore: merge dev
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Directory } from "@/acp/directory"
|
||||
import { Command } from "@/command"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -14,7 +15,7 @@ const command = (name: string): Command.Info => ({
|
||||
})
|
||||
|
||||
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
|
||||
id: ProviderV2.ModelID.make(id),
|
||||
id: ModelV2.ID.make(id),
|
||||
providerID,
|
||||
api: {
|
||||
id,
|
||||
@@ -50,7 +51,7 @@ const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.Model
|
||||
|
||||
const snapshot = (directory: string) => {
|
||||
const providerID = ProviderV2.ID.make(`provider-${directory}`)
|
||||
const modelID = ProviderV2.ModelID.make(`model-${directory}`)
|
||||
const modelID = ModelV2.ID.make(`model-${directory}`)
|
||||
const providers = {
|
||||
[providerID]: {
|
||||
id: providerID,
|
||||
@@ -63,7 +64,7 @@ const snapshot = (directory: string) => {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
}),
|
||||
[ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
|
||||
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
|
||||
},
|
||||
},
|
||||
} satisfies Record<ProviderV2.ID, Provider.Info>
|
||||
@@ -148,7 +149,7 @@ describe("ACP directory snapshot", () => {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
})
|
||||
expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined()
|
||||
expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined()
|
||||
}).pipe(Effect.provide(fakeLayer([]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Effect } from "effect"
|
||||
import * as ACPService from "@/acp/service"
|
||||
import * as ACPError from "@/acp/error"
|
||||
@@ -19,9 +20,9 @@ import { UsageService } from "@/acp/usage"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ProviderV2.ModelID.make("test-model")
|
||||
const configuredModelID = ProviderV2.ModelID.make("configured-model")
|
||||
const secondModelID = ProviderV2.ModelID.make("second-model")
|
||||
const modelID = ModelV2.ID.make("test-model")
|
||||
const configuredModelID = ModelV2.ID.make("configured-model")
|
||||
const secondModelID = ModelV2.ID.make("second-model")
|
||||
|
||||
const provider: Provider.Info = {
|
||||
id: providerID,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import * as ACPError from "@/acp/error"
|
||||
import * as ACPSession from "@/acp/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -10,7 +11,7 @@ const sessionTest = testEffect(ACPSession.defaultLayer)
|
||||
|
||||
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
|
||||
providerID: ProviderV2.ID.make(providerID),
|
||||
modelID: ProviderV2.ModelID.make(modelID),
|
||||
modelID: ModelV2.ID.make(modelID),
|
||||
})
|
||||
|
||||
const mcpServer: McpServer = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { UsageService } from "@/acp/usage"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Effect, Layer } from "effect"
|
||||
@@ -41,7 +42,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({
|
||||
},
|
||||
})
|
||||
|
||||
const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({
|
||||
const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
|
||||
id: modelID,
|
||||
providerID,
|
||||
api: {
|
||||
@@ -77,7 +78,7 @@ const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context:
|
||||
|
||||
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
|
||||
const providerID = ProviderV2.ID.make("anthropic")
|
||||
const modelID = ProviderV2.ModelID.make("claude-sonnet")
|
||||
const modelID = ModelV2.ID.make("claude-sonnet")
|
||||
return {
|
||||
[providerID]: {
|
||||
id: providerID,
|
||||
@@ -179,12 +180,12 @@ describe("acp usage", () => {
|
||||
const first = yield* usage.contextLimit({
|
||||
directory: "/workspace",
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet"),
|
||||
modelID: ModelV2.ID.make("claude-sonnet"),
|
||||
})
|
||||
const second = yield* usage.contextLimit({
|
||||
directory: "/workspace",
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet"),
|
||||
modelID: ModelV2.ID.make("claude-sonnet"),
|
||||
})
|
||||
|
||||
expect(first).toBe(200_000)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
events.emit(
|
||||
global({
|
||||
id: "agent-1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "model-1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "assistant-1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "input-1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "failed-1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
assistant.content[0].state.status === "error"
|
||||
)
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
expect(tool.state.status).toBe("error")
|
||||
if (tool.state.status !== "error") return
|
||||
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
||||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
"agent-switched",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
export namespace ProviderTest {
|
||||
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
|
||||
const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2")
|
||||
const id = override.id ?? ModelV2.ID.make("gpt-5.2")
|
||||
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
@@ -75,7 +76,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
|
||||
{
|
||||
model: {
|
||||
providerID: ProviderV2.ID.anthropic,
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
},
|
||||
},
|
||||
out,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createUnified } from "ai-gateway-provider/providers/unified"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import type * as Provider from "@/provider/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
type Captured = { url: string; outerBody: unknown }
|
||||
type ProviderOptions = Record<string, Record<string, JSONValue>>
|
||||
@@ -56,7 +57,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
|
||||
id: ProviderV2.ModelID.make(`cloudflare-ai-gateway/${apiId}`),
|
||||
id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`),
|
||||
providerID: ProviderV2.ID.make("cloudflare-ai-gateway"),
|
||||
name: apiId,
|
||||
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { streamText } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
@@ -31,7 +32,7 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", ()
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
@@ -55,7 +56,7 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () =
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
@@ -89,7 +90,7 @@ it.live("headerTimeout aborts when response headers do not arrive", () =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
@@ -121,7 +122,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
@@ -293,7 +294,7 @@ it.instance("getModel returns model for valid provider/model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||
@@ -306,7 +307,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const exit = yield* Provider.use
|
||||
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model"))
|
||||
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("nonexistent-model"))
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
@@ -315,7 +316,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
|
||||
it.instance("getModel throws ModelNotFoundError for invalid provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Provider.use
|
||||
.getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model"))
|
||||
.getModel(ProviderV2.ID.make("nonexistent-provider"), ModelV2.ID.make("some-model"))
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
@@ -464,7 +465,7 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined()
|
||||
|
||||
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet"))
|
||||
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("my-sonnet"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.id)).toBe("my-sonnet")
|
||||
expect(model.name).toBe("My Sonnet Alias")
|
||||
@@ -981,11 +982,11 @@ it.instance("getModel returns consistent results", () =>
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const model1 = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.anthropic,
|
||||
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
|
||||
ModelV2.ID.make("claude-sonnet-4-20250514"),
|
||||
)
|
||||
const model2 = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.anthropic,
|
||||
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
|
||||
ModelV2.ID.make("claude-sonnet-4-20250514"),
|
||||
)
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.id).toEqual(model2.id)
|
||||
@@ -1017,7 +1018,7 @@ it.instance("ModelNotFoundError includes suggestions for typos", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const error = yield* Provider.use
|
||||
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4"))
|
||||
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonet-4"))
|
||||
.pipe(Effect.flip)
|
||||
expect(error.suggestions).toBeDefined()
|
||||
expect((error.suggestions ?? []).length).toBeGreaterThan(0)
|
||||
@@ -1028,7 +1029,7 @@ it.instance("ModelNotFoundError for provider includes suggestions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const error = yield* Provider.use
|
||||
.getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4"))
|
||||
.getModel(ProviderV2.ID.make("antropic"), ModelV2.ID.make("claude-sonnet-4"))
|
||||
.pipe(Effect.flip)
|
||||
expect(error.suggestions).toBeDefined()
|
||||
expect(error.suggestions).toContain("anthropic")
|
||||
@@ -1039,7 +1040,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers",
|
||||
Effect.gen(function* () {
|
||||
yield* remove("OPENCODE_API_KEY")
|
||||
const error = yield* Provider.use
|
||||
.getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model"))
|
||||
.getModel(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-fake-model"))
|
||||
.pipe(Effect.flip)
|
||||
if (!Provider.ModelNotFoundError.isInstance(error)) throw error
|
||||
expect(error.suggestions ?? []).toContain("claude-haiku-4-5")
|
||||
@@ -1577,7 +1578,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex"),
|
||||
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
@@ -1593,7 +1594,7 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
@@ -1609,7 +1610,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex"),
|
||||
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
@@ -1700,13 +1701,13 @@ it.effect("plugin config providers persist after instance dispose", () =>
|
||||
|
||||
const first = yield* loadAndList
|
||||
expect(first[ProviderV2.ID.make("demo")]).toBeDefined()
|
||||
expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
|
||||
expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
|
||||
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
|
||||
const second = yield* loadAndList
|
||||
expect(second[ProviderV2.ID.make("demo")]).toBeDefined()
|
||||
expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
|
||||
expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
|
||||
}).pipe(provideMultiInstance),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
describe("ProviderTransform.options - setCacheKey", () => {
|
||||
const sessionID = "test-session-123"
|
||||
@@ -1089,7 +1090,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
||||
const result = ProviderTransform.message(
|
||||
msgs,
|
||||
{
|
||||
id: ProviderV2.ModelID.make("deepseek/deepseek-chat"),
|
||||
id: ModelV2.ID.make("deepseek/deepseek-chat"),
|
||||
providerID: ProviderV2.ID.make("deepseek"),
|
||||
api: {
|
||||
id: "deepseek-chat",
|
||||
@@ -1151,7 +1152,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
||||
const result = ProviderTransform.message(
|
||||
msgs,
|
||||
{
|
||||
id: ProviderV2.ModelID.make("openai/gpt-4"),
|
||||
id: ModelV2.ID.make("openai/gpt-4"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
api: {
|
||||
id: "gpt-4",
|
||||
@@ -3504,7 +3505,7 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
|
||||
describe("@jerome-benoit/sap-ai-provider-v2", () => {
|
||||
const sapModel = (apiId: string) =>
|
||||
const sapModel = (apiId: string, releaseDate = "2024-01-01") =>
|
||||
createMockModel({
|
||||
id: `sap-ai-core/${apiId}`,
|
||||
providerID: "sap-ai-core",
|
||||
@@ -3513,6 +3514,7 @@ describe("ProviderTransform.variants", () => {
|
||||
url: "https://api.ai.sap",
|
||||
npm: "@jerome-benoit/sap-ai-provider-v2",
|
||||
},
|
||||
release_date: releaseDate,
|
||||
})
|
||||
|
||||
for (const testCase of [
|
||||
@@ -3520,71 +3522,102 @@ describe("ProviderTransform.variants", () => {
|
||||
name: "sonnet 4.6",
|
||||
apiIds: ["anthropic--claude-sonnet-4-6"],
|
||||
efforts: ["low", "medium", "high", "max"],
|
||||
expectedHigh: { thinking: { type: "adaptive" }, effort: "high" },
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
{
|
||||
name: "opus 4.6",
|
||||
apiIds: ["anthropic--claude-4.6-opus", "anthropic--claude-4-6-opus"],
|
||||
efforts: ["low", "medium", "high", "max"],
|
||||
expectedHigh: { thinking: { type: "adaptive" }, effort: "high" },
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
{
|
||||
name: "opus 4.7",
|
||||
apiIds: ["anthropic--claude-4.7-opus", "anthropic--claude-4-7-opus"],
|
||||
efforts: ["low", "medium", "high", "xhigh", "max"],
|
||||
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
},
|
||||
{
|
||||
name: "opus 4.8",
|
||||
apiIds: ["anthropic--claude-4.8-opus", "anthropic--claude-4-8-opus"],
|
||||
efforts: ["low", "medium", "high", "xhigh", "max"],
|
||||
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
},
|
||||
]) {
|
||||
for (const apiId of testCase.apiIds) {
|
||||
test(`${testCase.name} ${apiId} returns adaptive thinking variants`, () => {
|
||||
test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => {
|
||||
const result = ProviderTransform.variants(sapModel(apiId))
|
||||
expect(Object.keys(result)).toEqual(testCase.efforts)
|
||||
expect(result.high).toEqual(testCase.expectedHigh)
|
||||
if (testCase.efforts.includes("xhigh")) {
|
||||
expect(result.xhigh).toEqual({ ...testCase.expectedHigh, effort: "xhigh" })
|
||||
for (const effort of testCase.efforts) {
|
||||
expect(result[effort]).toEqual({
|
||||
modelParams: {
|
||||
thinking: testCase.thinking,
|
||||
output_config: { effort },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("anthropic sonnet 4 returns budget-tokens variants", () => {
|
||||
const result = ProviderTransform.variants(sapModel("anthropic--claude-sonnet-4"))
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({ thinking: { type: "enabled", budgetTokens: 16000 } })
|
||||
expect(result.max).toEqual({ thinking: { type: "enabled", budgetTokens: 31999 } })
|
||||
})
|
||||
for (const apiId of ["anthropic--claude-sonnet-4", "anthropic--claude-4.5-opus"]) {
|
||||
test(`${apiId} returns budget_tokens variants under modelParams`, () => {
|
||||
const result = ProviderTransform.variants(sapModel(apiId))
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
modelParams: { thinking: { type: "enabled", budget_tokens: 16000 } },
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
modelParams: { thinking: { type: "enabled", budget_tokens: 31999 } },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("gemini 2.5 returns thinkingConfig variants", () => {
|
||||
const result = ProviderTransform.variants(sapModel("gcp--gemini-2.5-pro"))
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } })
|
||||
expect(result.max).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 24576 } })
|
||||
})
|
||||
for (const testCase of [
|
||||
{ apiId: "gemini-2.5-pro", maxBudget: 32768 },
|
||||
{ apiId: "gemini-2.5-flash", maxBudget: 24576 },
|
||||
]) {
|
||||
test(`${testCase.apiId} returns thinkingConfig variants under modelParams`, () => {
|
||||
const result = ProviderTransform.variants(sapModel(testCase.apiId))
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: testCase.maxBudget } },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const apiId of ["azure-openai--gpt-4o", "azure-openai--o3-mini"]) {
|
||||
test(`${apiId} returns reasoningEffort variants`, () => {
|
||||
for (const testCase of [
|
||||
{ apiId: "gpt-5", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] },
|
||||
{ apiId: "gpt-5-mini", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] },
|
||||
{ apiId: "gpt-5-nano", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] },
|
||||
{ apiId: "gpt-5.4", releaseDate: "2026-01-15", efforts: ["none", "low", "medium", "high", "xhigh"] },
|
||||
{ apiId: "azure-openai--o3-mini", releaseDate: "2024-01-01", efforts: ["low", "medium", "high"] },
|
||||
]) {
|
||||
test(`${testCase.apiId} returns reasoning_effort variants under modelParams`, () => {
|
||||
const result = ProviderTransform.variants(sapModel(testCase.apiId, testCase.releaseDate))
|
||||
expect(Object.keys(result)).toEqual(testCase.efforts)
|
||||
for (const effort of testCase.efforts) {
|
||||
expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const apiId of [
|
||||
"gemini-3.1-flash-lite",
|
||||
"cohere--command-a-reasoning",
|
||||
"sonar-deep-research",
|
||||
"aws--llama-opus-4.7-fake",
|
||||
]) {
|
||||
test(`${apiId} falls through to harmonized reasoning_effort fallback`, () => {
|
||||
const result = ProviderTransform.variants(sapModel(apiId))
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
for (const effort of ["low", "medium", "high"]) {
|
||||
expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const apiId of ["perplexity--sonar-pro", "mistral--mistral-large"]) {
|
||||
test(`${apiId} returns empty object`, () => {
|
||||
expect(ProviderTransform.variants(sapModel(apiId))).toEqual({})
|
||||
})
|
||||
}
|
||||
|
||||
test("non-anthropic models with opus-like substrings do not get adaptive thinking", () => {
|
||||
expect(ProviderTransform.variants(sapModel("aws--llama-opus-4.7-fake"))).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ai-gateway-provider (cloudflare-ai-gateway)", () => {
|
||||
|
||||
@@ -40,7 +40,15 @@ export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" |
|
||||
})
|
||||
}
|
||||
|
||||
const appCache: Partial<Record<string, BackendApp>> = {}
|
||||
type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
|
||||
|
||||
const appCache: Partial<Record<string, CachedApp>> = {}
|
||||
|
||||
export async function disposeApps() {
|
||||
const apps = Object.values(appCache)
|
||||
for (const key of Object.keys(appCache)) delete appCache[key]
|
||||
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
|
||||
}
|
||||
|
||||
function app(modules: Runtime, options: CallOptions) {
|
||||
const username = options.auth?.username
|
||||
@@ -48,7 +56,7 @@ function app(modules: Runtime, options: CallOptions) {
|
||||
const cacheKey = `${username ?? ""}:${password ?? ""}`
|
||||
if (appCache[cacheKey]) return appCache[cacheKey]
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
const web = HttpRouter.toWebHandler(
|
||||
modules.HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
@@ -57,10 +65,11 @@ function app(modules: Runtime, options: CallOptions) {
|
||||
),
|
||||
),
|
||||
{ disableLogger: true, memoMap: modules.memoMap },
|
||||
).handler
|
||||
)
|
||||
return (appCache[cacheKey] = {
|
||||
dispose: web.dispose,
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return handler(
|
||||
return web.handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
modules.HttpApiApp.context,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { color, printHeader, printResults } from "./report"
|
||||
import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing"
|
||||
import { runScenario } from "./runner"
|
||||
import { disposeApps } from "./backend"
|
||||
import { runtime } from "./runtime"
|
||||
import { type Scenario } from "./types"
|
||||
|
||||
@@ -656,7 +657,8 @@ const scenarios: Scenario[] = [
|
||||
.get("/api/provider/{providerID}", "v2.provider.get")
|
||||
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
|
||||
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
|
||||
@@ -677,7 +679,30 @@ const scenarios: Scenario[] = [
|
||||
body: { reply: "once" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, data(array)),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/request/{requestID}/reply", "v2.session.question.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reply owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/request/{requestID}/reply", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
body: { answers: [] },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/request/{requestID}/reject", "v2.session.question.reject")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reject owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/request/{requestID}/reject", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
|
||||
http.protected
|
||||
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
|
||||
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
|
||||
@@ -1432,7 +1457,7 @@ const llmScenarios = new Set([
|
||||
])
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => cleanupExercisePaths)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => disposeApps()).pipe(Effect.andThen(cleanupExercisePaths)))
|
||||
const options = parseOptions(Bun.argv.slice(2))
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||
|
||||
@@ -7,11 +7,12 @@ import type { Config } from "../../../src/config/config"
|
||||
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { call, callAuthProbe } from "./backend"
|
||||
import { call, callAuthProbe, disposeApps } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
export function runScenario(options: Options) {
|
||||
return (scenario: Scenario) => {
|
||||
@@ -153,7 +154,7 @@ function withContext<A, E>(
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
},
|
||||
}
|
||||
const part: SessionV1.TextPart = {
|
||||
@@ -259,6 +260,7 @@ const resetState = Effect.promise(async () => {
|
||||
const modules = await runtime()
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeApps()
|
||||
await modules.disposeAllInstances()
|
||||
await modules.resetDatabase()
|
||||
await Bun.sleep(25)
|
||||
|
||||
@@ -2,7 +2,7 @@ export type Runtime = {
|
||||
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
|
||||
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
|
||||
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
|
||||
memoMap: (typeof import("@opencode-ai/core/effect/memo-map"))["memoMap"]
|
||||
memoMap: import("effect").Layer.MemoMap
|
||||
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
|
||||
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
|
||||
Session: (typeof import("../../../src/session/session"))["Session"]
|
||||
@@ -22,7 +22,7 @@ export function runtime() {
|
||||
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
|
||||
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
|
||||
const appRuntime = await import("../../../src/effect/app-runtime")
|
||||
const memoMap = await import("@opencode-ai/core/effect/memo-map")
|
||||
const { Layer } = await import("effect")
|
||||
const instanceRef = await import("../../../src/effect/instance-ref")
|
||||
const instanceStore = await import("../../../src/project/instance-store")
|
||||
const session = await import("../../../src/session/session")
|
||||
@@ -36,7 +36,7 @@ export function runtime() {
|
||||
PublicApi: publicApi.PublicApi,
|
||||
HttpApiApp: httpApiServer.HttpApiApp,
|
||||
AppLayer: appRuntime.AppLayer,
|
||||
memoMap: memoMap.memoMap,
|
||||
memoMap: Layer.makeMemoMapUnsafe(),
|
||||
InstanceRef: instanceRef.InstanceRef,
|
||||
InstanceStore: instanceStore.InstanceStore,
|
||||
Session: session.Session,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type OpenApiSchema = {
|
||||
readonly $ref?: string
|
||||
readonly anyOf?: ReadonlyArray<OpenApiSchema>
|
||||
readonly type?: string
|
||||
readonly enum?: readonly unknown[]
|
||||
readonly properties?: Record<string, OpenApiSchema>
|
||||
@@ -22,6 +23,7 @@ type OpenApiOperation = {
|
||||
readonly schema?: { readonly type?: string }
|
||||
}>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly requestBody?: { readonly required?: boolean }
|
||||
readonly security?: unknown
|
||||
}
|
||||
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
|
||||
@@ -53,6 +55,12 @@ function componentName(ref: string) {
|
||||
return ref.replace("#/components/schemas/", "")
|
||||
}
|
||||
|
||||
function componentNames(response: OpenApiResponse | undefined) {
|
||||
const schema = response?.content?.["application/json"]?.schema
|
||||
if (!schema) return []
|
||||
return [schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))
|
||||
}
|
||||
|
||||
function isBuiltInEndpointError(name: string) {
|
||||
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
|
||||
}
|
||||
@@ -97,6 +105,18 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves required request bodies for v2 mutations", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const path of [
|
||||
"/api/session/{sessionID}/prompt",
|
||||
"/api/session/{sessionID}/permission/request/{requestID}/reply",
|
||||
"/api/session/{sessionID}/question/request/{requestID}/reply",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const refs = v2Operations(spec)
|
||||
@@ -165,7 +185,6 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/prompt"],
|
||||
["post", "/api/session/{sessionID}/compact"],
|
||||
["post", "/api/session/{sessionID}/wait"],
|
||||
] as const) {
|
||||
@@ -217,6 +236,15 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
"QuestionNotFoundError",
|
||||
)
|
||||
}
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/question/request/{requestID}/reply"],
|
||||
["post", "/api/session/{sessionID}/question/request/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
|
||||
"SessionNotFoundError",
|
||||
"QuestionNotFoundError",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents MCP server not-found errors", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
@@ -32,7 +33,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { httpApiLayer } from "./httpapi-layer"
|
||||
|
||||
@@ -60,13 +61,20 @@ type TestScope = Scope.Scope | TestServices
|
||||
function client(
|
||||
serverPath: ServerPath,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
input?: {
|
||||
password?: string
|
||||
username?: string
|
||||
headers?: Record<string, string>
|
||||
workspaceID?: string
|
||||
onRequest?: (request: Request) => void
|
||||
},
|
||||
) {
|
||||
return serverFetch(serverPath, input).pipe(
|
||||
Effect.map((fetch) =>
|
||||
createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
experimental_workspaceID: input?.workspaceID,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
}),
|
||||
@@ -74,7 +82,10 @@ function client(
|
||||
)
|
||||
}
|
||||
|
||||
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
function serverFetch(
|
||||
serverPath: ServerPath,
|
||||
input?: { password?: string; username?: string; onRequest?: (request: Request) => void },
|
||||
) {
|
||||
return HttpServer.HttpServer.use((server) =>
|
||||
Effect.sync(() => {
|
||||
void serverPath
|
||||
@@ -84,6 +95,7 @@ function serverFetch(serverPath: ServerPath, input?: { password?: string; userna
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const source = request instanceof Request ? request : new Request(request, init)
|
||||
input?.onRequest?.(source)
|
||||
const url = new URL(source.url)
|
||||
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
|
||||
},
|
||||
@@ -299,7 +311,7 @@ function seedMessage(directory: string, sessionID: string) {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
tools: {},
|
||||
} satisfies SessionV1.User)
|
||||
const part = yield* svc.updatePart({
|
||||
@@ -367,6 +379,31 @@ describe("HttpApi SDK", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
"routes configured SDK directory and workspace for v2 location GETs",
|
||||
withProject("raw", { setup: writeStandardFiles }, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = "wrk_sdk"
|
||||
let request: Request | undefined
|
||||
const sdk = yield* client("raw", directory, {
|
||||
workspaceID,
|
||||
onRequest: (value) => (request = value),
|
||||
})
|
||||
const file = yield* call(() => sdk.v2.fs.read({ path: "hello.txt" }))
|
||||
const url = new URL(request!.url)
|
||||
|
||||
expect(file.response.status).toBe(200)
|
||||
expect(file.data).toMatchObject({ content: "hello" })
|
||||
expect(url.searchParams.get("directory")).toBe(directory)
|
||||
expect(url.searchParams.get("workspace")).toBe(workspaceID)
|
||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
expect(request!.headers.has("x-opencode-directory")).toBe(false)
|
||||
expect(request!.headers.has("x-opencode-workspace")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -88,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
@@ -129,7 +129,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
|
||||
Effect.gen(function* () {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
@@ -151,6 +151,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
seq,
|
||||
time_created: time,
|
||||
data: {
|
||||
time: { created: time },
|
||||
@@ -162,6 +163,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return message
|
||||
})
|
||||
|
||||
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
||||
@@ -174,6 +176,7 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: sessionID,
|
||||
type: "assistant",
|
||||
seq: time,
|
||||
time_created: time,
|
||||
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
@@ -388,9 +391,9 @@ describe("session HttpApi", () => {
|
||||
yield* insertLegacyAssistantMessage(parent.id)
|
||||
|
||||
expect(
|
||||
(yield* requestJson<{ data: { items: SessionMessage.Message[] } }>(`/api/session/${parent.id}/message`, {
|
||||
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
|
||||
headers,
|
||||
})).data.items,
|
||||
})).data,
|
||||
).toMatchObject([{ type: "assistant" }])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
@@ -442,8 +445,8 @@ describe("session HttpApi", () => {
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 cursor" })
|
||||
yield* insertLegacyAssistantMessage(session.id, 1)
|
||||
yield* insertLegacyAssistantMessage(session.id, 2)
|
||||
const firstMessage = yield* insertLegacyAssistantMessage(session.id, 1, 2)
|
||||
const secondMessage = yield* insertLegacyAssistantMessage(session.id, 2, 1)
|
||||
|
||||
const sessionPage = yield* request(
|
||||
`/api/session?${new URLSearchParams({
|
||||
@@ -454,7 +457,7 @@ describe("session HttpApi", () => {
|
||||
})}`,
|
||||
{ headers },
|
||||
)
|
||||
const sessionCursor = (yield* json<{ data: { cursor: { next?: string } } }>(sessionPage)).data.cursor.next
|
||||
const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor.next
|
||||
expect(sessionCursor).toBeTruthy()
|
||||
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
|
||||
order: "asc",
|
||||
@@ -481,8 +484,32 @@ describe("session HttpApi", () => {
|
||||
})
|
||||
|
||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
||||
const messageCursor = (yield* json<{ data: { cursor: { next?: string } } }>(messagePage)).data.cursor.next
|
||||
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
|
||||
const messageCursor = messageBody.cursor.next
|
||||
expect(messageCursor).toBeTruthy()
|
||||
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
|
||||
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
|
||||
id: secondMessage.id,
|
||||
order: "desc",
|
||||
direction: "next",
|
||||
})
|
||||
|
||||
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, {
|
||||
headers,
|
||||
})
|
||||
expect(
|
||||
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
|
||||
).toEqual([firstMessage.id])
|
||||
|
||||
const legacyMessageCursor = Buffer.from(
|
||||
JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }),
|
||||
).toString("base64url")
|
||||
const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
|
||||
headers,
|
||||
})
|
||||
expect(
|
||||
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
|
||||
).toEqual([firstMessage.id])
|
||||
|
||||
const messageCursorWithOrder = yield* request(
|
||||
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
|
||||
@@ -544,6 +571,64 @@ describe("session HttpApi", () => {
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"durably records one v2 prompt for exact message-ID retries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 prompt recording" })
|
||||
|
||||
const recordPrompt = () =>
|
||||
request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
|
||||
})
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
type PromptBody = { id: string; type: string; text: string }
|
||||
const firstBody = yield* json<{ data: PromptBody }>(first)
|
||||
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
||||
expect(first.status).toBe(200)
|
||||
expect(retried.status).toBe(200)
|
||||
expect(retriedBody).toEqual(firstBody)
|
||||
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
|
||||
|
||||
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
headers,
|
||||
})
|
||||
expect(messages.data).toHaveLength(0)
|
||||
const admitted = yield* Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
expect(admitted).toMatchObject({
|
||||
id: "evt_http_prompt",
|
||||
session_id: session.id,
|
||||
delivery: "steer",
|
||||
promoted_seq: null,
|
||||
})
|
||||
|
||||
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
|
||||
})
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(yield* responseJson(conflict)).toEqual({
|
||||
_tag: "ConflictError",
|
||||
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
|
||||
resource: "evt_http_prompt",
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns v2 public unavailable errors for unfinished session mutations",
|
||||
() =>
|
||||
@@ -552,18 +637,6 @@ describe("session HttpApi", () => {
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 unavailable" })
|
||||
|
||||
const prompt = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt: { text: "hello" } }),
|
||||
})
|
||||
expect(prompt.status).toBe(503)
|
||||
expect(yield* responseJson(prompt)).toEqual({
|
||||
_tag: "ServiceUnavailableError",
|
||||
message: "V2 session prompt is not available yet",
|
||||
service: "v2.session.prompt",
|
||||
})
|
||||
|
||||
const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
|
||||
expect(compact.status).toBe(503)
|
||||
expect(yield* responseJson(compact)).toEqual({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
@@ -31,7 +32,7 @@ function seedNegativeTokenSession() {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Storage } from "@/storage/storage"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -79,7 +80,7 @@ describe("session diff with missing patch (#26574)", () => {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
|
||||
summary: {
|
||||
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -18,7 +19,7 @@ const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
const model = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { TestConfig } from "../fixture/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -47,7 +48,7 @@ const summary = Layer.succeed(
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test-model"),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
}
|
||||
|
||||
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirS
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer))
|
||||
|
||||
@@ -77,7 +78,7 @@ function loaded(filepath: string): SessionV1.WithParts[] {
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-20250514"),
|
||||
},
|
||||
},
|
||||
parts: [
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tool, type ModelMessage, type JSONValue } from "ai"
|
||||
import { Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
import { Auth } from "@/auth"
|
||||
@@ -28,6 +25,7 @@ import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
|
||||
|
||||
@@ -280,23 +278,21 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(ModelsDev.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||
const recordedHttp = HttpRecorder.cassetteLayer(scenario.cassette, {
|
||||
directory: FIXTURES_DIR,
|
||||
mode: shouldRecord ? "record" : "replay",
|
||||
metadata: {
|
||||
provider: scenario.providerID,
|
||||
protocol: scenario.protocol,
|
||||
route: scenario.protocol,
|
||||
tags: scenario.tags,
|
||||
},
|
||||
redactor: recordingRedactor,
|
||||
})
|
||||
const recordedClient = LLMClient.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(RequestExecutor.layer, WebSocketExecutor.layer)),
|
||||
Layer.provide(
|
||||
HttpRecorder.recordingLayer(scenario.cassette, {
|
||||
mode: shouldRecord ? "record" : "replay",
|
||||
metadata: {
|
||||
provider: scenario.providerID,
|
||||
protocol: scenario.protocol,
|
||||
route: scenario.protocol,
|
||||
tags: scenario.tags,
|
||||
},
|
||||
redactor: recordingRedactor,
|
||||
}).pipe(Layer.provide(FetchHttpClient.layer)),
|
||||
),
|
||||
Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)),
|
||||
)
|
||||
|
||||
return Layer.mergeAll(
|
||||
@@ -307,9 +303,6 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
||||
Layer.provide(provider),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(recordedClient),
|
||||
Layer.provide(
|
||||
HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(Layer.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
|
||||
),
|
||||
)
|
||||
@@ -376,7 +369,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
|
||||
|
||||
const stableID = scenario.stableID ?? scenario.providerID
|
||||
const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
|
||||
const modelID = ProviderV2.ModelID.make(model.id)
|
||||
const modelID = ModelV2.ID.make(model.id)
|
||||
const agent = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLMEvent, ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route"
|
||||
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
@@ -10,9 +10,10 @@ import type { Provider } from "@/provider/provider"
|
||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const baseModel: Provider.Model = {
|
||||
id: ProviderV2.ModelID.make("gpt-5-mini"),
|
||||
id: ModelV2.ID.make("gpt-5-mini"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
api: {
|
||||
id: "gpt-5-mini",
|
||||
@@ -535,6 +536,66 @@ describe("session.llm-native.request", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits native tool calls before overlapping local settlements complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[] = []
|
||||
const started: string[] = []
|
||||
let release: (() => void) | undefined
|
||||
let notifyStarted: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const bothStarted = new Promise<void>((resolve) => {
|
||||
notifyStarted = resolve
|
||||
})
|
||||
const lookup = {
|
||||
description: "Lookup data",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async (_args: unknown, options: { toolCallId: string }) => {
|
||||
started.push(options.toolCallId)
|
||||
if (started.length === 2) notifyStarted?.()
|
||||
await gate
|
||||
return { output: options.toolCallId }
|
||||
},
|
||||
} satisfies Tool
|
||||
const llmClient = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: () =>
|
||||
Stream.fromIterable([
|
||||
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-2", name: "lookup", input: {} }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]),
|
||||
generate: () => Effect.die("unused"),
|
||||
} as LLMClientShape
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: providerInfo,
|
||||
auth: undefined,
|
||||
llmClient,
|
||||
messages: [],
|
||||
tools: { lookup },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") throw new Error(native.reason)
|
||||
|
||||
const fiber = yield* native.stream.pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event.type))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.promise(() => bothStarted)
|
||||
|
||||
expect(started).toEqual(["call-1", "call-2"])
|
||||
expect(observed).toEqual(["tool-call", "tool-call", "finish"])
|
||||
|
||||
release?.()
|
||||
yield* Fiber.join(fiber)
|
||||
expect(observed).toEqual(["tool-call", "tool-call", "finish", "tool-result", "tool-result"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compiles through the native OpenAI Responses route", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [storedSession.user("hello")],
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Permission } from "@/permission"
|
||||
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
|
||||
|
||||
@@ -768,7 +769,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(vivgridFixture.providerID),
|
||||
ProviderV2.ModelID.make(fixture.model.id),
|
||||
ModelV2.ID.make(fixture.model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-1")
|
||||
const agent = {
|
||||
@@ -842,7 +843,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(alibabaQwenFixture.providerID),
|
||||
ProviderV2.ModelID.make(fixture.model.id),
|
||||
ModelV2.ID.make(fixture.model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-service-abort")
|
||||
const agent = {
|
||||
@@ -910,7 +911,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(alibabaQwenFixture.providerID),
|
||||
ProviderV2.ModelID.make(fixture.model.id),
|
||||
ModelV2.ID.make(fixture.model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-tools")
|
||||
const agent = {
|
||||
@@ -1013,7 +1014,7 @@ describe("session.llm.stream", () => {
|
||||
]
|
||||
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1118,7 +1119,7 @@ describe("session.llm.stream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1188,7 +1189,7 @@ describe("session.llm.stream", () => {
|
||||
]
|
||||
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1272,7 +1273,7 @@ describe("session.llm.stream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1360,7 +1361,7 @@ describe("session.llm.stream", () => {
|
||||
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||
let executed: unknown
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1486,7 +1487,7 @@ describe("session.llm.stream", () => {
|
||||
),
|
||||
).toString("base64")}`
|
||||
|
||||
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
|
||||
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",
|
||||
@@ -1575,7 +1576,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(minimaxFixture.providerID),
|
||||
ProviderV2.ModelID.make(model.id),
|
||||
ModelV2.ID.make(model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-3")
|
||||
const agent = {
|
||||
@@ -1593,7 +1594,7 @@ describe("session.llm.stream", () => {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: { providerID: ProviderV2.ID.make("minimax"), modelID: ProviderV2.ModelID.make("MiniMax-M2.5") },
|
||||
model: { providerID: ProviderV2.ID.make("minimax"), modelID: ModelV2.ID.make("MiniMax-M2.5") },
|
||||
} satisfies SessionV1.User
|
||||
|
||||
yield* drain({
|
||||
@@ -1672,7 +1673,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make("anthropic"),
|
||||
ProviderV2.ModelID.make(model.id),
|
||||
ModelV2.ID.make(model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-anthropic-tools")
|
||||
const agent = {
|
||||
@@ -1874,7 +1875,7 @@ describe("session.llm.stream", () => {
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(geminiFixture.providerID),
|
||||
ProviderV2.ModelID.make(model.id),
|
||||
ModelV2.ID.make(model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-4")
|
||||
const agent = {
|
||||
|
||||
@@ -8,11 +8,12 @@ import type { Provider } from "@/provider/provider"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { Question } from "../../src/question"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const sessionID = SessionID.make("session")
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const model: Provider.Model = {
|
||||
id: ProviderV2.ModelID.make("test-model"),
|
||||
id: ModelV2.ID.make("test-model"),
|
||||
providerID,
|
||||
api: {
|
||||
id: "test-model",
|
||||
@@ -67,7 +68,7 @@ function userInfo(id: string): SessionV1.User {
|
||||
role: "user",
|
||||
time: { created: 0 },
|
||||
agent: "user",
|
||||
model: { providerID, modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID, modelID: ModelV2.ID.make("test") },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as SessionV1.User
|
||||
@@ -413,7 +414,7 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
test("preserves jpeg tool-result media for anthropic models", async () => {
|
||||
const anthropicModel: Provider.Model = {
|
||||
...model,
|
||||
id: ProviderV2.ModelID.make("anthropic/claude-opus-4-7"),
|
||||
id: ModelV2.ID.make("anthropic/claude-opus-4-7"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
api: {
|
||||
id: "claude-opus-4-7-20250805",
|
||||
@@ -496,7 +497,7 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
test("moves bedrock pdf tool-result media into a separate user message", async () => {
|
||||
const bedrockModel: Provider.Model = {
|
||||
...model,
|
||||
id: ProviderV2.ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
|
||||
id: ModelV2.ID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
|
||||
providerID: ProviderV2.ID.make("amazon-bedrock"),
|
||||
api: {
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
@@ -1044,7 +1045,7 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
const assistantID = "m-assistant"
|
||||
const openrouterModel: Provider.Model = {
|
||||
...model,
|
||||
id: ProviderV2.ModelID.make("deepseek/deepseek-v4-pro"),
|
||||
id: ModelV2.ID.make("deepseek/deepseek-v4-pro"),
|
||||
providerID: ProviderV2.ID.make("openrouter"),
|
||||
api: {
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NotFoundError } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -98,7 +99,7 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* (
|
||||
role: "assistant",
|
||||
time: { created: Date.now() },
|
||||
parentID,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
mode: "",
|
||||
agent: "default",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { expect } from "bun:test"
|
||||
import { tool } from "ai"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
@@ -25,11 +25,14 @@ import { SessionSummary } from "../../src/session/summary"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -44,7 +47,7 @@ const summary = Layer.succeed(
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test-model"),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
@@ -198,6 +201,58 @@ const env = Layer.mergeAll(
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
const providerErrorLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-1", name: "lookup" }),
|
||||
LLMEvent.toolInputEnd({ id: "call-1", name: "lookup" }),
|
||||
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {}, providerExecuted: true }),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-1",
|
||||
name: "lookup",
|
||||
result: { type: "error", value: "provider boom" },
|
||||
providerExecuted: true,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const providerErrorEnv = SessionProcessor.layer.pipe(
|
||||
Layer.provide(summary),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
Layer.provide(providerErrorLLM),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const itProviderError = testEffect(providerErrorEnv)
|
||||
|
||||
const fragmentFailureLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||
LLMEvent.providerError({ message: "provider boom" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = SessionProcessor.layer.pipe(
|
||||
Layer.provide(summary),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
Layer.provide(fragmentFailureLLM),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const itFragmentFailure = testEffect(fragmentFailureEnv)
|
||||
|
||||
const boot = Effect.fn("test.boot")(function* () {
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
@@ -936,3 +991,111 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
||||
{ config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
itProviderError.live("session.processor effect tests fail provider-executed error results", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "provider tool error")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = []
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === SessionEvent.Tool.Failed.type)
|
||||
settlements.push(event as typeof SessionEvent.Tool.Failed.Type)
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
|
||||
yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "provider tool error" }],
|
||||
tools: {},
|
||||
})
|
||||
yield* off
|
||||
|
||||
const parts = yield* MessageV2.parts(msg.id)
|
||||
const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
|
||||
expect(call?.state.status).toBe("error")
|
||||
if (call?.state.status === "error") expect(call.state.error).toBe("provider boom")
|
||||
expect(settlements).toHaveLength(1)
|
||||
expect(settlements[0]?.data).toMatchObject({
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "provider boom" },
|
||||
result: { type: "error", value: "provider boom" },
|
||||
provider: { executed: true },
|
||||
})
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "provider failure")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const seen: string[] = []
|
||||
let text: string | undefined
|
||||
let reasoning: string | undefined
|
||||
const off = yield* events.listen((event) => {
|
||||
seen.push(event.type)
|
||||
if (event.type === SessionEvent.Text.Ended.type)
|
||||
text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text
|
||||
if (event.type === SessionEvent.Reasoning.Ended.type)
|
||||
reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
|
||||
expect(
|
||||
yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "provider failure" }],
|
||||
tools: {},
|
||||
}),
|
||||
).toBe("stop")
|
||||
yield* off
|
||||
|
||||
const failed = seen.indexOf(SessionEvent.Step.Failed.type)
|
||||
expect(failed).toBeGreaterThan(-1)
|
||||
expect(seen.indexOf(SessionEvent.Text.Ended.type)).toBeLessThan(failed)
|
||||
expect(seen.indexOf(SessionEvent.Reasoning.Ended.type)).toBeLessThan(failed)
|
||||
expect(text).toBe("partial")
|
||||
expect(reasoning).toBe("thinking")
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -71,7 +72,7 @@ const summary = Layer.succeed(
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test-model"),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
}
|
||||
|
||||
function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
|
||||
@@ -320,10 +321,9 @@ const useServerConfig = Effect.fn("test.useServerConfig")(function* (config: (ur
|
||||
return { dir, llm }
|
||||
})
|
||||
|
||||
// Wait for a session's runner to enter a busy state. SessionStatus is flipped to
|
||||
// "busy" inside Runner.startShell's modifyEffect at the same moment the runner
|
||||
// is registered, so this is a deterministic readiness signal — cancel can't
|
||||
// no-op once we observe it.
|
||||
// Wait for a session's runner to enter a busy state. SessionStatus is flipped
|
||||
// inside Runner.startShell's serialized transition, so cancel can't no-op once
|
||||
// we observe it.
|
||||
const waitForBusy = (sessionID: SessionID, duration: Duration.Input = "2 seconds") =>
|
||||
pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
@@ -760,7 +760,7 @@ it.instance("failed subtask preserves metadata on error tool state", () =>
|
||||
expect(tool.state.metadata?.sessionId).toBeDefined()
|
||||
expect(tool.state.metadata?.model).toEqual({
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("missing-model"),
|
||||
modelID: ModelV2.ID.make("missing-model"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -2206,7 +2206,7 @@ noLLMServer.instance(
|
||||
const other = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") },
|
||||
model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") },
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
@@ -2222,7 +2222,7 @@ noLLMServer.instance(
|
||||
if (match.info.role !== "user") throw new Error("expected user message")
|
||||
expect(match.info.model).toEqual({
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test-model"),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
variant: "xhigh",
|
||||
})
|
||||
expect(match.info.model.variant).toBe("xhigh")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -33,7 +34,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de
|
||||
role: "user" as const,
|
||||
sessionID,
|
||||
agent,
|
||||
model: { providerID: ProviderV2.ID.make("openai"), modelID: ProviderV2.ModelID.make("gpt-4") },
|
||||
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
})
|
||||
@@ -49,7 +50,7 @@ const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, p
|
||||
path: { cwd: dir, root: dir },
|
||||
cost: 0,
|
||||
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
@@ -117,7 +118,7 @@ describe("revert + compact workflow", () => {
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
@@ -149,7 +150,7 @@ describe("revert + compact workflow", () => {
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
parentID: userMsg1.id,
|
||||
time: {
|
||||
@@ -174,7 +175,7 @@ describe("revert + compact workflow", () => {
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
@@ -206,7 +207,7 @@ describe("revert + compact workflow", () => {
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
parentID: userMsg2.id,
|
||||
time: {
|
||||
@@ -279,7 +280,7 @@ describe("revert + compact workflow", () => {
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
@@ -311,7 +312,7 @@ describe("revert + compact workflow", () => {
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ProviderV2.ModelID.make("gpt-4"),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
providerID: ProviderV2.ID.make("openai"),
|
||||
parentID: userMsg.id,
|
||||
time: {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const configLayer = TestConfig.layer({
|
||||
@@ -124,7 +125,7 @@ describe("tool.registry", () => {
|
||||
if (!build) throw new Error("build agent not found")
|
||||
const task = (yield* registry.tools({
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
agent: build,
|
||||
})).find((tool) => tool.id === "task")
|
||||
|
||||
@@ -302,7 +303,7 @@ describe("tool.registry", () => {
|
||||
const agents = yield* Agent.Service
|
||||
const promptTools = yield* registry.tools({
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
})
|
||||
const promptTool = promptTools.find((tool) => tool.id === "sql")
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -28,7 +29,7 @@ afterEach(async () => {
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test-model"),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
}
|
||||
|
||||
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
@@ -81,6 +82,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
variant: "xhigh",
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
yield* session.updateMessage(assistant)
|
||||
@@ -242,6 +244,7 @@ describe("tool.task", () => {
|
||||
expect(result.metadata.sessionId).toBe(child.id)
|
||||
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
|
||||
expect(seen?.sessionID).toBe(child.id)
|
||||
expect(seen?.variant).toBe("xhigh")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -586,6 +589,7 @@ describe("tool.task", () => {
|
||||
expect(waited.info?.status).toBe("completed")
|
||||
expect(waited.info?.output).toBe("second done")
|
||||
const notification = yield* Effect.promise(() => injected.promise)
|
||||
expect(notification.variant).toBe("xhigh")
|
||||
expect(notification.parts[0]?.type).toBe("text")
|
||||
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done")
|
||||
}),
|
||||
|
||||
@@ -7,14 +7,16 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
|
||||
test.skip("step snapshots carry over to assistant messages", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
id: assistantMessageID,
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
@@ -36,6 +38,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
||||
type: "session.next.step.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
@@ -84,6 +87,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
textID: "text-1",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -95,6 +99,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
@@ -102,17 +107,18 @@ test.skip("text ended populates assistant text content", () => {
|
||||
|
||||
expect(state.messages[0]?.type).toBe("assistant")
|
||||
if (state.messages[0]?.type !== "assistant") return
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
|
||||
})
|
||||
|
||||
test.skip("tool completion stores completed timestamp", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const callID = "call"
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
id: assistantMessageID,
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
@@ -133,6 +139,7 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
type: "session.next.tool.input.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
callID,
|
||||
name: "bash",
|
||||
@@ -146,11 +153,12 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
type: "session.next.tool.called",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: true, metadata: { source: "provider" } },
|
||||
provider: { executed: true, metadata: { fake: { source: "provider" } } },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -161,11 +169,12 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
type: "session.next.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(4),
|
||||
callID,
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/tmp" }],
|
||||
provider: { executed: true, metadata: { status: "done" } },
|
||||
content: [ToolOutput.text({ type: "text", text: "/tmp" })],
|
||||
provider: { executed: true, metadata: { fake: { status: "done" } } },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -175,7 +184,7 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
expect(state.messages[0].content[0]?.type).toBe("tool")
|
||||
if (state.messages[0].content[0]?.type !== "tool") return
|
||||
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } })
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
|
||||
})
|
||||
|
||||
test.skip("compaction events reduce to compaction message", () => {
|
||||
|
||||
Reference in New Issue
Block a user