feat(core): add command registry (#30624)

This commit is contained in:
Dax
2026-06-04 02:57:43 -04:00
committed by GitHub
parent 70bb710715
commit 1ff19103a2
150 changed files with 4753 additions and 2657 deletions

View File

@@ -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([]))),
)

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -10,6 +10,7 @@ import { Provider } from "@/provider/provider"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
@@ -113,7 +114,7 @@ it.instance(
() =>
Effect.gen(function* () {
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ProviderV2.ModelID.make("openai.gpt-5.5"))
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
@@ -143,7 +144,7 @@ it.instance(
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(
ProviderV2.ID.amazonBedrock,
ProviderV2.ModelID.make("openai.gpt-oss-safeguard-120b"),
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
)
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")

View File

@@ -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" },

View File

@@ -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" }],

View File

@@ -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),
)

View File

@@ -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"
@@ -1123,7 +1124,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",
@@ -1185,7 +1186,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",

View File

@@ -43,6 +43,22 @@ function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
function data(validate: (value: any) => void) {
return (body: any) => {
object(body)
validate(body.data)
}
}
function locationData(validate: (value: any) => void) {
return (body: any) => {
object(body)
object(body.location)
object(body.location.project)
validate(body.data)
}
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@@ -609,20 +625,48 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}),
),
http.protected.get("/api/model", "v2.model.list").json(200, array),
http.protected.get("/api/provider", "v2.provider.list").json(200, array),
http.protected.get("/api/health", "v2.health.get").json(200, (body) => {
object(body)
check(body.healthy === true, "v2 server should report healthy")
}),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
http.protected
.get("/api/event", "v2.event.subscribe")
.stream()
.status(
200,
(ctx, result) =>
Effect.sync(() => {
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location")
}),
"status",
),
http.protected
.get("/api/fs/read", "v2.fs.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, object),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array),
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected
.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, array),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
@@ -630,7 +674,7 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@@ -666,7 +710,10 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
object(body)
array(body.data)
}),
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() }))
@@ -678,7 +725,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@@ -701,7 +748,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@@ -723,7 +770,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",

View File

@@ -12,6 +12,7 @@ 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 = {

View File

@@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type OpenApiSchema = {
readonly $ref?: string
readonly items?: OpenApiSchema
readonly properties?: Record<string, OpenApiSchema>
}
type OpenApiSpec = {
readonly components?: { readonly schemas?: Record<string, OpenApiSchema> }
readonly paths: Record<
string,
{
readonly get?: {
readonly responses?: Record<string, { readonly content?: Record<string, { schema?: OpenApiSchema }> }>
}
}
>
}
function responseSchema(spec: OpenApiSpec, path: string) {
return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema
}
function componentName(ref: string | undefined) {
return ref?.replace("#/components/schemas/", "")
}
describe("PublicApi v2 catalog redaction", () => {
test("routes use redacted provider and model DTO schemas", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const provider = responseSchema(spec, "/api/provider/{providerID}")
const providers = responseSchema(spec, "/api/provider")
const models = responseSchema(spec, "/api/model")
expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo")
const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties
const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties
expect(providerProperties).not.toHaveProperty("request")
expect(modelProperties).not.toHaveProperty("request")
expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/)
expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/)
})
test("DTOs sanitize provider and model API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const providers = [
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "native",
url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment",
settings: {},
},
}),
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment",
},
}),
].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider)))
const models = [
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")),
api: {
id: ModelV2.ID.make("native"),
type: "native",
url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment",
settings: {},
},
}),
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")),
api: {
id: ModelV2.ID.make("aisdk"),
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment",
},
}),
].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model)))
expect(providers.map((provider) => provider.api)).toEqual([
{ type: "native", url: "https://example.com:8443" },
{ type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" },
])
expect(models.map((model) => model.api)).toEqual([
{ id: "native", type: "native", url: "https://example.com:9443" },
{ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" },
])
expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/)
})
test("DTOs omit malformed API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const provider = Schema.encodeSync(ProviderV2.PublicInfo)(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} },
}),
),
)
const modelID = ModelV2.ID.make("aisdk")
const model = Schema.encodeSync(ModelV2.PublicInfo)(
ModelV2.toPublic(
new ModelV2.Info({
...ModelV2.Info.empty(providerID, modelID),
api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" },
}),
),
)
expect(provider.api).toEqual({ type: "native" })
expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" })
expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/)
})
})

View File

@@ -3,7 +3,14 @@ import { OpenApi } from "effect/unstable/httpapi"
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> }
type OpenApiSchema = {
readonly $ref?: string
readonly anyOf?: ReadonlyArray<OpenApiSchema>
readonly type?: string
readonly enum?: readonly unknown[]
readonly properties?: Record<string, OpenApiSchema>
readonly required?: readonly string[]
}
type OpenApiResponse = {
readonly description?: string
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
@@ -20,7 +27,10 @@ type OpenApiOperation = {
readonly security?: unknown
}
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
type OpenApiSpec = {
readonly paths: Record<string, OpenApiPathItem>
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
}
const methods = ["get", "post", "put", "delete", "patch"] as const
@@ -56,6 +66,23 @@ function isBuiltInEndpointError(name: string) {
}
describe("PublicApi OpenAPI v2 errors", () => {
test("documents nested legacy global sync events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const schema = spec.components.schemas.SyncEventSessionCreated
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
expect(schema?.properties?.type?.enum).toEqual(["sync"])
expect(schema?.properties?.syncEvent).toMatchObject({
required: ["type", "id", "seq", "aggregateID", "data"],
properties: {
type: { enum: ["session.created.1"] },
id: { type: "string" },
seq: { type: "number" },
aggregateID: { type: "string" },
},
})
})
test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec

View File

@@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"

View File

@@ -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()

View File

@@ -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"
@@ -310,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({
@@ -392,7 +393,7 @@ describe("HttpApi SDK", () => {
const url = new URL(request!.url)
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ content: "hello" })
expect(file.data).toMatchObject({ data: { content: "hello" } })
expect(url.searchParams.get("directory")).toBe(directory)
expect(url.searchParams.get("workspace")).toBe(workspaceID)
expect(url.searchParams.get("location[directory]")).toBe(directory)

View File

@@ -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({
@@ -391,8 +391,9 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id)
expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
.items,
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
headers,
})).data,
).toMatchObject([{ type: "assistant" }])
}),
{ git: true, config: { formatter: false, lsp: false } },
@@ -456,7 +457,7 @@ describe("session HttpApi", () => {
})}`,
{ headers },
)
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).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",
@@ -483,10 +484,10 @@ describe("session HttpApi", () => {
})
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageCursor = messageBody.cursor.next
expect(messageCursor).toBeTruthy()
expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id])
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",
@@ -497,7 +498,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from(
@@ -507,7 +508,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request(
@@ -587,17 +588,17 @@ describe("session HttpApi", () => {
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
type PromptBody = { id: string; type: string; text: string }
const firstBody = yield* json<PromptBody>(first)
const retriedBody = yield* json<PromptBody>(retried)
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({ type: "user", text: "hello" })
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, {
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers,
})
expect(messages.items).toHaveLength(0)
expect(messages.data).toHaveLength(0)
const admitted = yield* Database.Service.use(({ db }) =>
db
.select()

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context, Schema } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return HttpApiApp.webHandler().handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
)
}
const Event = Schema.Struct({
id: Schema.String,
type: Schema.String,
location: Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
data: Schema.Unknown,
})
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const value = await reader.read()
if (value.done) throw new Error("event stream closed")
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
}
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
for (let index = 0; index < 20; index++) {
const event = await readEvent(reader)
if (event.type === type) return event
}
throw new Error(`timed out waiting for ${type}`)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("v2 location HttpApi", () => {
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
for (const route of ["/api/command", "/api/skill"]) {
const response = await request(route, tmp.path)
expect(response.status).toBe(200)
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
expect(body.data).toBeArray()
expect(body.location.directory).toBe(tmp.path)
expect(body.location.project.id).toBeTruthy()
}
})
test("streams native EventV2 payloads with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path)
const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected")
const created = await request("/session", tmp.path, { method: "POST" })
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } },
data: { sessionID: expect.any(String) },
})
await reader.cancel()
})
})

View File

@@ -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()

View File

@@ -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" }],
},

View File

@@ -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 () => {

View File

@@ -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)

View File

@@ -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: [

View File

@@ -25,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")
@@ -368,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",

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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",

View File

@@ -30,6 +30,7 @@ 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"
@@ -46,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 = {

View File

@@ -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>) {
@@ -759,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"),
})
}),
)
@@ -2213,7 +2214,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" }],
})
@@ -2229,7 +2230,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")

View File

@@ -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: {

View File

@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
@@ -14,6 +15,7 @@ import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global"
void Log.init({ print: false })
@@ -101,6 +103,31 @@ describe("session.created event", () => {
yield* session.remove(info.id)
}),
)
it.instance("emits legacy global sync payload", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const received = yield* Deferred.make<{ syncEvent: EventV2.SerializedEvent }>()
const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => {
if (event.payload.type === "sync" && event.payload.syncEvent)
Deferred.doneUnsafe(received, Effect.succeed({ syncEvent: event.payload.syncEvent }))
}
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
const info = yield* session.create({})
const event = yield* awaitDeferred(received, "timed out waiting for legacy global sync event")
expect(event.syncEvent).toMatchObject({
type: EventV2.versionedType(SessionNs.Event.Created.type, 1),
seq: 0,
aggregateID: info.id,
data: { sessionID: info.id },
})
yield* session.remove(info.id)
}),
)
})
describe("step-finish token propagation via event", () => {

View File

@@ -18,7 +18,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
const env = Layer.mergeAll(
Session.defaultLayer,
@@ -301,7 +301,11 @@ describe("ShareNext", () => {
},
],
})
yield* Effect.sleep(1_250)
yield* pollWithTimeout(
Effect.sync(() => (seen.length === 1 ? true : undefined)),
"timed out waiting for share sync",
"5 seconds",
)
expect(seen).toHaveLength(1)
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync")

View File

@@ -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")

View File

@@ -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> = {}) =>