Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 7c0492cbc5 refactor(core): resolve provider settings in plugins 2026-08-06 17:42:18 -05:00
Aiden Cline 16f144465b refactor(core): generalize OpenAI-compatible routing 2026-08-06 15:56:38 -05:00
Aiden Cline 478d0bd533 refactor(core): route Workers AI natively 2026-08-06 13:46:04 -05:00
122 changed files with 6917 additions and 8133 deletions
@@ -12,6 +12,7 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -19,6 +20,8 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly queryParams?: Readonly<Record<string, string>>
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -31,11 +34,11 @@ export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
const provider = input.provider ?? "openai-compatible"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, queryParams, ...rest } = input
const route = OpenAICompatibleChat.route.with({
...rest,
provider,
endpoint: { baseURL },
endpoint: { baseURL, query: queryParams },
auth: AuthOptions.bearer(input, []),
})
return {
@@ -75,6 +78,8 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}).model(modelID)
export const baseten = define(profiles.baseten)
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
+18
View File
@@ -64,6 +64,24 @@ describe("provider package entrypoints", () => {
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps OpenAI-compatible package settings onto the executable model", async () => {
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
const selected = OpenAICompatible.model("custom-model", {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
provider: "example",
queryParams: { version: "preview" },
providerOptions: { openai: { reasoningEffort: "high" } },
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://provider.example.test/v1",
query: { version: "preview" },
})
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
-26
View File
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "List all available models",
params: ServerParams,
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
...ServerParams,
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to export to stdout"),
Flag.optional,
),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
),
},
}),
Spec.make("import", {
description: "Import session data from a JSON file or URL",
params: {
...ServerParams,
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
directory: Flag.string("directory").pipe(
Flag.withDescription("Directory in which to import the session"),
Flag.optional,
),
},
}),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -1,140 +0,0 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}
@@ -1,60 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Session } from "@opencode-ai/schema/session"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.import,
Effect.fn("cli.import")(function* (input) {
const text = yield* Effect.tryPromise({
try: () =>
input.file.startsWith("http://") || input.file.startsWith("https://")
? fetch(input.file).then((response) => {
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
return response.text()
})
: Bun.file(input.file).text(),
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const location = yield* Effect.promise(() =>
client.location.get({
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/session/import", server.endpoint.url), {
method: "POST",
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
body: JSON.stringify({
...encoded,
location: { directory: location.directory, workspaceID: location.workspaceID },
}),
}),
)
if (response.status === 409) {
process.stderr.write(`Session already exists${EOL}`)
return
}
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
)(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}),
)
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
-2
View File
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/plugin/list"),
},
models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
-210
View File
@@ -1,210 +0,0 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import { writeExport } from "../src/commands/handlers/export"
const info = {
id: "ses_export_test",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Exported session",
location: { directory: "/project" },
}
const transfer = {
info,
messages: [
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
],
}
const sanitizedTransfer = {
info: {
...info,
title: "[redacted:session-title:ses_export_test]",
location: { directory: "/[redacted:session-directory:ses_export_test]" },
},
messages: [
{
id: "msg_first",
type: "user",
text: "[redacted:text:msg_first]",
time: { created: 1 },
},
{
id: "msg_second",
type: "user",
text: "[redacted:text:msg_second]",
time: { created: 2 },
},
],
}
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
stdin: stdin === undefined ? undefined : new Blob([stdin]),
stdout: "pipe",
stderr: "pipe",
})
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
}
test("export is raw by default and supports explicit sanitization", async () => {
const sanitization: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
const exported = JSON.parse(stdout)
expect(exitCode).toBe(0)
expect(exported).toEqual(transfer)
const [sanitized, , sanitizedExitCode] = await run([
"export",
"-s",
info.id,
"--sanitize",
"--server",
server.url.toString(),
])
expect(sanitizedExitCode).toBe(0)
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
expect(sanitization).toEqual(["false", "true"])
} finally {
await server.stop(true)
}
}, 15_000)
test("export reports an empty session list without a stack trace", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
project: { id: "global", directory: "/project", canonical: "/project" },
})
}
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`No sessions found${os.EOL}`)
} finally {
await server.stop(true)
}
})
test("interactive export writes a temporary JSON file", async () => {
const output = await writeExport(transfer, info.id, false)
const file = output.trim()
try {
expect(path.dirname(file)).toBe(os.tmpdir())
expect(await Bun.file(file).json()).toEqual(transfer)
} finally {
await fs.rm(file, { force: true })
}
})
test("import validates a file and sends it to the resolved location", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
let imported: unknown
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") {
imported = await request.json()
return Response.json({ data: { ...info, location: { directory: root } } })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run([
"import",
file,
"--directory",
root,
"--server",
server.url.toString(),
])
expect(exitCode).toBe(0)
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
expect(imported).toEqual({ ...transfer, location: { directory: root } })
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
test("import reports an existing session without a stack trace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`Session already exists${os.EOL}`)
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
+83 -97
View File
@@ -127,54 +127,42 @@ export type Endpoint5_1Input = {
export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = {
readonly info: Session.Info
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly location?: Location.Ref | undefined
}
export type Endpoint5_2Output = Session.Info
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
export type Endpoint5_3Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
export type Endpoint5_4Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_5Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_7Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_9Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_10Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_15Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_17Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_19Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_25Input = {
export type Endpoint5_23Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_25Output = void
export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_26Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_28Output =
export type Endpoint5_26Output =
| (
| {
readonly id: Event.ID
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
+83 -107
View File
@@ -21,10 +21,10 @@ import type {
Endpoint5_0Output,
Endpoint5_1Input,
Endpoint5_1Output,
Endpoint5_2Input,
Endpoint5_2Output,
Endpoint5_3Input,
Endpoint5_3Output,
Endpoint5_4Input,
Endpoint5_4Output,
Endpoint5_5Input,
Endpoint5_5Output,
@@ -76,10 +76,6 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
),
)
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
preserveEffect<Endpoint5_2Output>()(
raw["session.import"]({
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
}).pipe(
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -333,23 +327,20 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
preserveEffect<Endpoint5_3Output>()(
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
preserveEffect<Endpoint5_4Output>()(
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect<Endpoint5_5Output>()(
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -357,48 +348,35 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()(
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -416,8 +394,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -437,16 +415,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -463,29 +441,29 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -495,19 +473,35 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -515,45 +509,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_28Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -565,18 +543,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -586,32 +564,30 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
import: Endpoint5_2(raw),
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(raw),
remove: Endpoint5_6(raw),
fork: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw),
rename: Endpoint5_10(raw),
move: Endpoint5_11(raw),
prompt: Endpoint5_12(raw),
command: Endpoint5_13(raw),
skill: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw),
shell: Endpoint5_16(raw),
compact: Endpoint5_17(raw),
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_27(raw),
log: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
active: Endpoint5_2(raw),
get: Endpoint5_3(raw),
remove: Endpoint5_4(raw),
fork: Endpoint5_5(raw),
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(raw),
move: Endpoint5_9(raw),
prompt: Endpoint5_10(raw),
command: Endpoint5_11(raw),
skill: Endpoint5_12(raw),
synthetic: Endpoint5_13(raw),
shell: Endpoint5_14(raw),
compact: Endpoint5_15(raw),
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -15,10 +15,6 @@ import type {
SessionListOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
SessionImportOutput,
SessionExportInput,
SessionExportOutput,
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionImportOutput }>(
{
method: "POST",
path: `/api/session/import`,
body: { info: input["info"], messages: input["messages"], location: input["location"] },
successStatus: 200,
declaredStatuses: [409, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionExportOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
query: { sanitize: input["sanitize"] },
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionActiveOutput }>(
{
File diff suppressed because it is too large Load Diff
+22
View File
@@ -12,6 +12,7 @@ export interface Mapping {
export interface MapInput {
readonly packageName: string | undefined
readonly providerID: string
readonly settings: Readonly<Record<string, unknown>>
readonly modelID: string
}
@@ -51,6 +52,8 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/openai-compatible":
return mapOpenAICompatible(input, baseSettings)
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -63,6 +66,25 @@ export function map(input: MapInput): Mapping | undefined {
},
}
}
return undefined
}
function mapOpenAICompatible(
input: MapInput,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping | undefined {
if (typeof baseSettings.baseURL !== "string") return undefined
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
baseURL: baseSettings.baseURL,
...mapAPIKey(input.settings),
provider: input.providerID,
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapOpenAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
+16 -34
View File
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable"
import { ConfigNormalize } from "./config/normalize"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { WellKnown } from "./wellknown"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
const parseInfo = (text: string) => {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
if (errors.length) return
return Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
)
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
}
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
const info = parseInfo(substituted)
if (!info) return
return new Document({ type: "document", path: filepath, info })
})
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map(parseInfo),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
Effect.orDie,
)
: []
const content = options?.content !== undefined
const content = options?.content
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map(parseInfo),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
-813
View File
@@ -1,813 +0,0 @@
export * as ConfigNormalize from "./normalize"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent"
import { ConfigAttachmentV1 } from "../v1/config/attachment"
import { ConfigCommandV1 } from "../v1/config/command"
import { ConfigMCPV1 } from "../v1/config/mcp"
import { ConfigPermissionV1 } from "../v1/config/permission"
import { ConfigPluginV1 } from "../v1/config/plugin"
import { ConfigProviderV1 } from "../v1/config/provider"
import { ConfigMigrateV1 } from "../v1/config/migrate"
import { PositiveInt } from "../schema"
export interface Diagnostic {
readonly kind: "conflict" | "invalid" | "unsupported"
readonly path: readonly string[]
readonly message: string
}
export type Result =
| {
readonly type: "normalized"
readonly encoded: Readonly<Record<string, unknown>>
readonly diagnostics: readonly Diagnostic[]
}
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
const unsupportedExperimental = [
"disable_paste_summary",
"batch_tool",
"openTelemetry",
"primary_tools",
"continue_loop_on_deny",
] as const
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
export function normalize(input: unknown): Result {
if (!isRecord(input))
return {
type: "rejected",
diagnostics: [
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
],
}
const diagnostics: Diagnostic[] = []
const encoded: Record<string, unknown> = {}
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
const legacySnapshots = own(input, "snapshot")
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
: undefined
: undefined
const legacyMedia = own(input, "attachment")
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
: undefined
if (legacyMedia !== undefined) {
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
legacyReferences,
nativeReferences,
isRecord(input.reference) || isRecord(input.references),
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
migratedCommands,
nativeCommands,
isRecord(input.command) || isRecord(input.commands),
diagnostics,
)
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
: undefined
const migratedSmallModel = legacySmallModel
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
: undefined
if (legacySmallModel && !migratedSmallModel)
diagnostics.push({
kind: "unsupported",
path: ["small_model"],
message: "omitted unsupported legacy model reference",
})
if (migratedSmallModel)
legacyAgents.title = {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
encoded,
"agents",
migratedAgents,
nativeAgents,
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics,
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
legacyProviders,
nativeProviders,
isRecord(input.provider) || isRecord(input.providers),
diagnostics,
)
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
normalizeSkills(input, encoded, diagnostics)
normalizeMcp(input, encoded, diagnostics)
normalizeCompaction(input, encoded, diagnostics)
normalizeExperimental(input, encoded, diagnostics)
normalizeWatcher(input, encoded, diagnostics)
normalizeFormatter(input, encoded, diagnostics)
normalizeLsp(input, encoded, diagnostics)
const nativeAtomic = {
$schema: Info.fields.$schema,
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
autoupdate: Info.fields.autoupdate,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
snapshots: Info.fields.snapshots,
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
if (!own(input, key)) return
const value = decodeEncoded(schema, input[key], [key], diagnostics)
if (value === undefined) return
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
}
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
invalid(["skills"], diagnostics)
return
}
encoded.skills = [
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
const legacyServers: Record<string, unknown> = {}
const nativeServers: Record<string, unknown> = {}
const timeout: Record<string, unknown> = {}
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
const value = decodeEncoded(
PositiveInt,
input.experimental.mcp_timeout,
["experimental", "mcp_timeout"],
diagnostics,
)
if (value !== undefined) {
timeout.catalog = value
timeout.execution = value
}
}
if (own(input, "mcp")) {
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
if (isRecord(input.mcp)) {
Object.entries(input.mcp).forEach(([name, value]) => {
const path = ["mcp", name]
if (isEnabledOnlyMcp(value)) {
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
if (name === "timeout" && !isDirectLegacyMcp(value)) {
normalizeMcpTimeout(value, timeout, path, diagnostics)
return
}
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
if (server !== undefined)
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
})
}
}
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
return
}
encoded.mcp = {
...(Object.keys(timeout).length ? { timeout } : {}),
...(Object.keys(servers).length ? { servers } : {}),
}
}
function normalizeMcpTimeout(
value: unknown,
timeout: Record<string, unknown>,
path: string[],
diagnostics: Diagnostic[],
) {
if (!isRecord(value)) {
invalid(path, diagnostics)
return
}
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
}
function normalizeCompaction(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "compaction")) return
if (!isRecord(input.compaction)) {
invalid(["compaction"], diagnostics)
return
}
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
const result: Record<string, unknown> = {}
if (own(input.compaction, "auto")) {
const value = decodeEncoded(
ConfigCompaction.Info.fields.auto,
input.compaction.auto,
["compaction", "auto"],
diagnostics,
)
if (value !== undefined) result.auto = value
}
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
input.compaction.preserve_recent_tokens,
["compaction", "preserve_recent_tokens"],
diagnostics,
)
: undefined
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
const nativeTokens =
nativeKeep && own(nativeKeep, "tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
nativeKeep.tokens,
["compaction", "keep", "tokens"],
diagnostics,
)
: undefined
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
if (tokens !== undefined) result.keep = { tokens }
const legacyBuffer = own(input.compaction, "reserved")
? decodeEncoded(
ConfigCompaction.Info.fields.buffer,
input.compaction.reserved,
["compaction", "reserved"],
diagnostics,
)
: undefined
const nativeBuffer = own(input.compaction, "buffer")
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
: undefined
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
if (buffer !== undefined) result.buffer = buffer
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
}
function normalizeExperimental(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
const result: Record<string, unknown> = {}
const generated: unknown[] = []
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
generated.push(
...enabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "allow",
})),
)
}
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
generated.push(
...disabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "deny",
})),
)
const native: unknown[] = []
if (own(input, "experimental")) {
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
if (isRecord(input.experimental)) {
const experimental = input.experimental
unsupportedExperimental.forEach((key) =>
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
)
if (own(experimental, "subagent_depth")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_depth,
experimental.subagent_depth,
["experimental", "subagent_depth"],
diagnostics,
)
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
result.policies = [...generated, ...native]
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
encoded.experimental = result
}
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "watcher")) return
if (!isRecord(input.watcher)) {
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
function normalizeFormatter(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "formatter")) return
if (typeof input.formatter === "boolean") {
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "lsp")) return
if (typeof input.lsp === "boolean") {
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (!isRecord(value)) {
invalid(["tools"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
if (enabled === undefined) return []
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
})
}
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (typeof value === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
}
if (!isRecord(value)) {
invalid(["permission"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
if (typeof raw === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
}
if (!isRecord(raw)) {
invalid(["permission", action], diagnostics)
return []
}
return Object.entries(raw).flatMap(([resource, effect], index) => {
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
return decoded === undefined
? []
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
})
})
}
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(["provider"], diagnostics)
return {}
}
const candidates = Object.entries(value).flatMap(([name, raw]) => {
const path = ["provider", name]
diagnoseProviderUnsupported(raw, path, diagnostics)
if (invalidProviderOverlays(raw, path, diagnostics)) return []
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
if (provider === undefined) return []
const destination = ConfigMigrateV1.providerID(name)
return [
{
name,
destination,
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
},
]
})
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
const result: Record<string, unknown> = {}
candidates.forEach((item) => {
if (item.name !== item.destination && current.has(item.destination)) return
setOwn(result, item.destination, item.provider)
})
return result
}
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value) || !isRecord(value.options)) return false
const headersInvalid =
own(value.options, "headers") &&
(!isPlainRecord(value.options.headers) ||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
return headersInvalid || bodyInvalid
}
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
if (!isRecord(value.models)) return
Object.entries(value.models).forEach(([name, model]) => {
if (!isRecord(model)) return
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
if (own(model, "status") && model.status !== "deprecated")
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
})
}
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, agent]) => {
if (!isRecord(agent)) return
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
diagnoseSelection(agent, [...path, name], diagnostics)
})
}
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, entry]) => {
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
})
}
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
if (own(value, "model") && typeof value.model === "string" && !modelValid)
diagnostics.push({
kind: "unsupported",
path: [...path, "model"],
message: "omitted unsupported legacy model reference",
})
if (
own(value, "variant") &&
typeof value.variant === "string" &&
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
)
diagnostics.push({
kind: "unsupported",
path: [...path, "variant"],
message: "omitted unsupported legacy model variant",
})
}
function decodeProviderList(
input: Record<string, unknown>,
key: "enabled_providers" | "disabled_providers",
diagnostics: Diagnostic[],
) {
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
if (!Array.isArray(input[key])) {
invalid([key], diagnostics)
return { present: true, nonEmpty: true, values: [] as string[] }
}
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isSome(decoded)) return decoded.value
invalid(path, diagnostics)
return undefined
}
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isNone(decoded)) {
invalid(path, diagnostics)
return undefined
}
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
if (Option.isSome(encoded)) return plain(encoded.value)
invalid(path, diagnostics)
return undefined
}
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
return plain(
Option.getOrThrow(
Schema.decodeUnknownOption(
schema,
options,
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
),
)
}
function plain(value: unknown): unknown {
if (Array.isArray(value)) return value.map(plain)
if (!isRecord(value)) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
)
}
function mergeMap(
target: Record<string, unknown>,
key: string,
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
present: boolean,
diagnostics: Diagnostic[],
) {
const merged = mergeMaps(legacy, native, [key], diagnostics)
if (present) target[key] = merged
}
function mergeMaps(
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
path: string[],
diagnostics: Diagnostic[],
) {
const result = Object.fromEntries(Object.entries(legacy))
Object.entries(native).forEach(([name, value]) => {
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
setOwn(result, name, value)
})
return result
}
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => {
const mapped = map(value)
return mapped === undefined ? [] : [[key, mapped]]
}),
)
}
function overlay(
target: Record<string, unknown>,
key: string,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
target[key] = value
}
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
if (native === undefined) return legacy
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
return native
}
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
if (!own(value, key)) return
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
}
function invalid(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
}
function conflict(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
}
function isDirectLegacyMcp(value: unknown) {
return isRecord(value) && (value.type === "local" || value.type === "remote")
}
function isEnabledOnlyMcp(value: unknown) {
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function own(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
}
+3 -6
View File
@@ -161,14 +161,11 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home
if (resource === "$HOME") return home
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
return resource
}
+8 -8
View File
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
export interface Target {
readonly absolute: string
readonly canonical: string
readonly resource: string
}
@@ -37,7 +37,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
/**
* Serialize file changes by absolute target. Conditional writes compare and
* Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
*/
@@ -49,11 +49,11 @@ const layer = Layer.effect(
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.absolute,
target: target.canonical,
resource: target.resource,
existed,
})
@@ -61,8 +61,8 @@ const layer = Layer.effect(
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* fs.exists(input.target.absolute)
yield* fs.writeWithDirs(input.target.absolute, input.content)
const existed = yield* fs.exists(input.target.canonical)
yield* fs.writeWithDirs(input.target.canonical, input.content)
return writeResult(input.target, existed)
}),
),
@@ -73,10 +73,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.absolute)
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
input.target.canonical,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
+79 -28
View File
@@ -23,9 +23,14 @@ export const ResolveInput = Schema.Struct({
})
export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literal("non_directory_ancestor"),
}) {}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
/** Canonical existing directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
@@ -39,9 +44,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
})
export interface Target {
/** Absolute lexical path. */
readonly absolute: string
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
/** Canonical existing path, or missing path below a canonical directory. */
readonly canonical: string
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
@@ -52,11 +57,25 @@ export interface Interface {
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
interface ResolvedPath {
readonly canonical: string
readonly type?:
| "File"
| "Directory"
| "SymbolicLink"
| "BlockDevice"
| "CharacterDevice"
| "FIFO"
| "Socket"
| "Unknown"
readonly directory: string
}
const slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
@@ -65,33 +84,65 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
}
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* notFound(fs.realPath(absolute))
if (existing !== undefined) {
const info = yield* fs.stat(existing)
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : path.dirname(existing),
} satisfies ResolvedPath
}
let anchor = path.dirname(absolute)
while (true) {
const canonical = yield* notFound(fs.realPath(anchor))
if (canonical !== undefined) {
const info = yield* fs.stat(canonical)
if (info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
directory: canonical,
} satisfies ResolvedPath
}
const parent = path.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
// External access follows the requested path boundary. Symlinks reached through an
// internal path intentionally retain internal permission semantics after canonicalization.
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
const resolved = yield* resolvePath(absolute)
const external = !lexicallyInternal
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
const externalDirectory =
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
),
},
canonical: resolved.canonical,
resource,
externalDirectory: external
? {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
),
}
: undefined,
} satisfies Target
})
+30 -17
View File
@@ -5,8 +5,6 @@ import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
@@ -18,6 +16,7 @@ import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { PluginHooks } from "./plugin/hooks"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -135,6 +134,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly resolveProvider?: (input: {
readonly model: Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}) => Effect.Effect<Record<string, unknown>>
}
export const fromCatalogModel = (
@@ -144,8 +148,6 @@ export const fromCatalogModel = (
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
})
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
@@ -164,21 +166,11 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
providerID: resolved.providerID,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
})
@@ -257,7 +249,25 @@ export const resolveModel = (
variant: VariantID | undefined,
credential?: Credential.Value,
dependencies?: Dependencies,
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
) =>
withVariant(model, variant).pipe(
Effect.flatMap((model) => {
if (!dependencies?.resolveProvider) return fromCatalogModel(model, credential, dependencies)
return dependencies
.resolveProvider({ model, credential, settings: { ...model.settings, ...credential?.metadata } })
.pipe(
Effect.flatMap((settings) =>
fromCatalogModel(
produce(model, (draft) => {
draft.settings = settings
}),
credential,
dependencies,
),
),
)
}),
)
export const supported = (model: Info) => Boolean(model.package)
@@ -269,6 +279,7 @@ export const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const hooks = yield* PluginHooks.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
@@ -281,6 +292,8 @@ export const layer = Layer.effect(
{
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
resolveProvider: (input) =>
hooks.trigger("provider", "resolve", input).pipe(Effect.map((event) => event.settings)),
},
)
return {
@@ -318,5 +331,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, PluginHooks.node],
})
+2
View File
@@ -1,6 +1,7 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { ProviderHooks } from "@opencode-ai/plugin/effect/provider"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
@@ -10,6 +11,7 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly provider: ProviderHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
+3
View File
@@ -273,6 +273,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
plugin: {
list: () => response(plugin.list()),
},
provider: {
hook: (name, callback) => hooks.register("provider", name, callback),
},
reference: {
list: () => response(reference.list()),
reload: reference.reload,
+4
View File
@@ -84,6 +84,10 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
provider: {
hook: (name, callback) =>
register(host.provider.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command),
@@ -14,72 +14,34 @@ export const CloudflareWorkersAIPlugin = define({
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (!Provider.isAISDK(provider.package)) return
if (typeof provider.settings?.baseURL === "string") return
const accountId = resolveAccountId(provider.settings ?? {})
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
const baseURL = resolveBaseURL(provider.settings ?? {})
if (baseURL) provider.settings = { ...provider.settings, baseURL }
provider.headers = Provider.mergeHeaders(provider.headers, {
"User-Agent": `${App.useragent(ctx.app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
})
})
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.provider.hook(
"resolve",
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
const accountId = resolveAccountId(evt.options)
if (!hasWorkersEndpoint(evt.model) && !accountId) return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(
sdkOptions(
{
...evt.options,
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
},
ctx.app,
) as any,
)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
const baseURL = resolveBaseURL(evt.settings)
if (baseURL) evt.settings.baseURL = baseURL
}),
)
}),
})
function resolveAccountId(options: Record<string, unknown>) {
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
function resolveBaseURL(options: Record<string, unknown>) {
const baseURL = stringOption(options, "baseURL")
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
if (!accountId) return baseURL
if (baseURL) return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
return workersEndpoint(accountId)
}
function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
}
function hasWorkersEndpoint(model: {
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
}) {
return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
}
function sdkOptions(options: Record<string, any>, app: App.Info) {
return {
...options,
baseURL: expandAccountId(options.baseURL),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: {
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
...options.headers,
},
name: providerID,
}
}
function expandAccountId(baseURL: unknown) {
if (typeof baseURL !== "string") return baseURL
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
}
function stringOption(options: Record<string, unknown>, key: string) {
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
-323
View File
@@ -1,323 +0,0 @@
export * as SessionTransfer from "./transfer"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Bus } from "../bus"
import { Database } from "../database/database"
import { Location } from "../location"
import { Project } from "../project"
import { ProjectTable } from "../project/sql"
import { AbsolutePath, RelativePath } from "../schema"
import { Session } from "../session"
import { Slug } from "../util/slug"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionProjector } from "./projector"
import { SessionMessageTable, SessionTable } from "./sql"
export const Data = SessionTransfer.Data
export type Data = SessionTransfer.Data
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
"SessionTransfer.ImportConflictError",
{ sessionID: Session.ID },
) {}
export interface Interface {
readonly export: (input: {
sessionID: Session.ID
sanitize?: boolean
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
readonly import: (input: {
data: Data
location: Location.Ref
}) => Effect.Effect<Session.Info, ImportConflictError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const app = yield* App.Metadata
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const projects = yield* Project.Service
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
return Service.of({
export: Effect.fn("SessionTransfer.export")(function* (input) {
const data = {
info: yield* sessions.get(input.sessionID),
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
}
return input.sanitize ? sanitize(data) : data
}),
import: Effect.fn("SessionTransfer.import")(function* (input) {
const sessionID = input.data.info.id
const recorded = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (recorded) return yield* new ImportConflictError({ sessionID })
const project = yield* projects.resolve(input.location.directory)
yield* persistProject(project)
const messages = input.data.messages.map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id: message.id,
session_id: sessionID,
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data,
}
})
yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
location: input.location,
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
title: input.data.info.title,
agent: input.data.info.agent,
model: input.data.info.model,
},
{
location: input.location,
commit: (seq) =>
Effect.gen(function* () {
if (messages.length > 0) {
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
}
yield* db
.update(SessionTable)
.set({
cost: input.data.info.cost,
tokens_input: input.data.info.tokens.input,
tokens_output: input.data.info.tokens.output,
tokens_reasoning: input.data.info.tokens.reasoning,
tokens_cache_read: input.data.info.tokens.cache.read,
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
},
)
.pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionProjector.SessionAlreadyProjected
? Effect.fail(new ImportConflictError({ sessionID }))
: Effect.die(defect),
),
)
return yield* sessions.get(sessionID).pipe(Effect.orDie)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
})
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
}
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
if (!value) return value
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
}
function sanitize(data: Data): Data {
return {
info: {
...data.info,
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
location: {
...data.info.location,
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
},
revert: data.info.revert
? {
...data.info.revert,
files: data.info.revert.files?.map((file, index) => ({
...file,
file: redact("revert-file", String(index), file.file),
patch: redact("revert-patch", String(index), file.patch),
})),
}
: undefined,
},
messages: data.messages.map(sanitizeMessage),
}
}
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
const meta = metadata("message-metadata", message.id, message.metadata)
if (message.type === "user")
return {
...message,
metadata: meta,
text: redact("text", message.id, message.text),
files: message.files?.map((file, index) => ({
...file,
data: "",
source: { type: "inline" },
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
description:
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
mention: file.mention
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
: undefined,
})),
agents: message.agents?.map((agent, index) => ({
...agent,
name: redact("agent-name", String(index), agent.name),
mention: agent.mention
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
: undefined,
})),
}
if (message.type === "synthetic")
return {
...message,
metadata: meta,
text: redact("synthetic", message.id, message.text),
description:
message.description === undefined
? undefined
: redact("synthetic-description", message.id, message.description),
}
if (message.type === "system")
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
if (message.type === "shell")
return {
...message,
metadata: meta,
command: redact("shell-command", message.id, message.command),
output: message.output
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
: undefined,
}
if (message.type === "assistant")
return {
...message,
metadata: meta,
content: message.content.map((content) => {
if (content.type === "text")
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
}
return {
...content,
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
providerResultState: content.providerResultState
? { redacted: `tool-provider-result-state:${message.id}` }
: undefined,
state: sanitizeToolState(message.id, content.state),
}
}),
}
if (message.type === "compaction") {
if (message.status === "failed")
return {
...message,
metadata: meta,
}
return {
...message,
metadata: meta,
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
}
}
return { ...message, metadata: meta }
}
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
if (state.status === "running")
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
if (state.status === "completed")
return {
...state,
input: { redacted: `tool-input:${id}` },
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
metadata: meta,
}
}
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
return {
...content,
uri: redact("tool-file-uri", id, content.uri),
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
}
}
+114 -110
View File
@@ -116,120 +116,124 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false, permission: "edit" },
description:
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description:
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
const info = yield* fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
)
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.canonical)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const exact = findOccurrences(source, oldString)
// These one-to-one mappings preserve offsets into the original source.
const unicode =
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const trailing =
exact.length > 0 || unicode.length > 0
? []
: findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.canonical))
? yield* Bom.syncFile(fs, target.canonical, bom)
: (yield* Bom.readFile(fs, target.canonical)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const exact = findOccurrences(source, oldString)
// These one-to-one mappings preserve offsets into the original source.
const unicode =
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute))
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
},
}),
},
}),
),
)
.pipe(Effect.orDie)
}),
+79 -76
View File
@@ -50,93 +50,96 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
draft.add(
({
name,
options: { codemode: false },
description:
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: target.absolute,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
const info = yield* fs
.stat(target.canonical)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: target.canonical,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}),
),
}),
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}),
),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
}),
+259 -246
View File
@@ -58,7 +58,7 @@ type Prepared =
})
interface Target {
readonly absolute: string
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
@@ -76,256 +76,267 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
input: Input,
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
})
}
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
)
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
input: Input,
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
})
}
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
})
return
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
),
)
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
}),
),
)
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.absolute)
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.canonical,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = Bom.split(original).text
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.absolute,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
}),
),
)
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = Bom.split(original).text
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
})
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
),
)
}
prepared.push({
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* fs
.writeWithDirs(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.type === "delete") {
yield* fs
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
yield* fs
.writeWithDirs(change.target.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
}),
{ discard: true },
)
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files }
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
: new ToolFailure({ message: "Unable to apply patch", error }),
),
)
}
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* fs
.writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.absolute,
})
return
}
if (change.type === "delete") {
yield* fs
.remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.absolute,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs
.remove(change.target.absolute)
.pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.absolute,
})
return
}
yield* fs
.writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.absolute,
})
}),
{ discard: true },
)
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
})
return { applied, files }
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
),
)
},
}),
},
}),
),
)
.pipe(Effect.orDie)
@@ -354,7 +365,9 @@ function errorMessage(error: unknown) {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
@@ -403,22 +416,22 @@ function trimDiff(diff: string) {
}
function resolveTarget(location: Location.Interface, value: string): Target {
const absolute =
const canonical =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, absolute) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
const directory = path.dirname(absolute)
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
absolute,
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+92 -85
View File
@@ -25,7 +25,11 @@ const LocationInput = Schema.Struct({
}),
})
export const Input = LocationInput
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
const Output = Schema.Union([
ReadToolFileSystem.FileContent,
ReadToolFileSystem.TextPage,
ReadToolFileSystem.ListPage,
])
export const Plugin = {
id: "opencode.tool.read",
@@ -39,102 +43,105 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description:
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path })
const external = target.externalDirectory
if (external)
draft.add(
({
name,
options: { codemode: false },
description:
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.absolute)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.absolute)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
const type = yield* reader.inspect(absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
)
},
}),
},
}),
),
)
.pipe(Effect.orDie)
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
Effect.map((entries) =>
entries
.filter((entry) => {
+150 -152
View File
@@ -122,176 +122,174 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
)
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
draft.add(
({
name,
options: { codemode: false },
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* fsUtil.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
yield* context.progress({ shellID: info.id })
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
})
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
})
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
output: capture.output,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
status: "completed" as const,
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
})
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
const result = yield* runtime.job
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return yield* Deferred.await(settled)
}).pipe(
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
return yield* Deferred.await(settled)
}).pipe(
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
+55 -77
View File
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { Effect, Schema } from "effect"
import { Form } from "../../form"
import { KV } from "../../kv"
import { Permission } from "../../permission"
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -30,7 +29,6 @@ export const Plugin = {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
.transform((draft) =>
@@ -51,90 +49,70 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return yield* Effect.void
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (
typeof providerID !== "string" ||
!providers.some((provider) => provider.id === providerID)
)
return yield* new WebSearch.ProviderRequiredError()
return yield* kv.set("websearch:provider", providerID)
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.andThen(Effect.suspend(search)),
)
}),
)
const result = yield* search()
: undefined
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", providerID)
return yield* ctx.websearch.query(input)
})
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
+53 -46
View File
@@ -54,52 +54,59 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false, permission: "edit" },
description:
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description:
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(
target.resource,
current?.text ?? "",
next.text,
current ? "modified" : "added",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
),
)
.pipe(Effect.orDie)
}),
+87 -39
View File
@@ -34,6 +34,14 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
}
}
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
resource: Schema.String,
}) {
override get message() {
return `File is not valid UTF-8: ${this.resource}`
}
}
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number },
@@ -53,7 +61,13 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
}
export type InspectError = FSUtil.Error | PathKindError
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
export type ReadError =
| FSUtil.Error
| BinaryFileError
| MediaIngestLimitError
| MalformedUtf8Error
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
@@ -76,15 +90,9 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
next: Schema.optionalKey(PositiveInt),
}) {}
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
export const ListEntry = Schema.Struct({
path: RelativePath,
type: Schema.Literals(["file", "directory", "symlink"]),
}).annotate({ identifier: "ReadTool.ListEntry" })
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
type: Schema.Literal("list-page"),
entries: Schema.Array(ListEntry),
entries: Schema.Array(FileSystem.Entry),
truncated: Schema.Boolean,
next: Schema.optionalKey(PositiveInt),
}) {}
@@ -101,6 +109,36 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const extensions = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
@@ -110,7 +148,8 @@ const mediaMime = (bytes: Uint8Array) => {
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (bytes: Uint8Array) => {
const binary = (resource: string, bytes: Uint8Array) => {
if (extensions.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
@@ -119,9 +158,16 @@ const binary = (bytes: Uint8Array) => {
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
Effect.try({
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
catch: (error) => {
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
throw error
},
})
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input)
@@ -172,17 +218,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
mime,
}
}
if (extensions.has(path.extname(resource).toLowerCase()))
return yield* Effect.fail(new BinaryFileError({ resource }))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* decodeUtf8(resource, decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(decodeUtf8(decoder))
text.push(yield* decodeUtf8(resource, decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
@@ -195,7 +243,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
@@ -253,8 +301,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
start = end
}
return true
@@ -266,7 +314,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = decodeUtf8(decoder)
const tail = yield* decodeUtf8(resource, decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
@@ -288,26 +336,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = items
.flatMap((item) =>
item.type === "other"
? []
: [
ListEntry.make({
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
],
)
.sort((a, b) =>
a.type === "directory"
? b.type === "directory"
? a.path.localeCompare(b.path)
: -1
: b.type === "directory"
? 1
: a.path.localeCompare(b.path),
)
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const absolute = path.join(real, item.name)
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!target || !FSUtil.contains(real, target)) return
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
})
}),
{ concurrency: 16 },
)
const visible = entries
.filter((item): item is FileSystem.Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length
return new ListPage({
+94 -81
View File
@@ -1,8 +1,5 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -13,54 +10,82 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
),
const keys = new Set([
"logLevel",
"server",
"command",
"reference",
"snapshot",
"plugin",
"autoshare",
"disabled_providers",
"enabled_providers",
"small_model",
"mode",
"agent",
"provider",
"permission",
"tools",
"attachment",
"layout",
])
export function isV1(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const record = input as Record<string, unknown>
if (Object.keys(record).some((key) => keys.has(key))) return true
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
const mcp = record.mcp
return (
typeof mcp === "object" &&
mcp !== null &&
!Array.isArray(mcp) &&
!("servers" in mcp) &&
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
)
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
@@ -107,7 +132,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
export function normalizeAction(action: string) {
function normalizeAction(action: string) {
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
@@ -119,16 +144,8 @@ function agents(info: typeof ConfigV1.Info.Type) {
...Object.entries(info.agent ?? {}),
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
]
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
const small = modelSelection(info.small_model)
if (!small) return entries.length ? result : undefined
return {
...result,
title: {
model: small,
...result.title,
},
}
if (!entries.length) return undefined
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
}
export function migrateAgent(info: ConfigAgentV1.Info) {
@@ -137,25 +154,21 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
}
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([id, command]) => [
@@ -192,7 +205,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
export function migrateMcp(info: ConfigMCPV1.Info) {
function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
return {
@@ -231,7 +244,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
)
}
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
return migrateStandardProvider(info)
@@ -243,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
headers: info.options && options.headers,
body: info.options && options.body,
models:
@@ -287,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
}
}
// Rename these only while migrating unambiguous V1 fields.
export function providerID(input: string) {
// Rename these only in files detected as V1 by a field that exists only in the old config format.
function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex"
return input
-1
View File
@@ -126,7 +126,6 @@ describe("Agent", () => {
yield* agent.transform((editor) => editor.update(id, () => {}))
const info = yield* agent.get(id)
expect(info?.mode).toBe("primary")
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
+52 -2
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
AISDKNative.map({ packageName, settings, modelID })
const map = (
packageName: string,
settings: Readonly<Record<string, unknown>>,
modelID = "test-model",
providerID = "test-provider",
) => AISDKNative.map({ packageName, providerID, settings, modelID })
describe("AISDKNative", () => {
test("maps both models.dev Bedrock packages to native providers", () => {
@@ -41,6 +45,52 @@ describe("AISDKNative", () => {
)
})
test("maps Cloudflare Workers AI to the generic OpenAI-compatible provider", () => {
expect(
map(
"@ai-sdk/openai-compatible",
{
accountId: "account/id",
apiKey: "secret",
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
headers: { "x-custom": "value" },
queryParams: { version: "preview" },
reasoningEffort: "high",
},
"@cf/model",
"cloudflare-workers-ai",
),
).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
apiKey: "secret",
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
provider: "cloudflare-workers-ai",
queryParams: { version: "preview" },
providerOptions: { openai: { reasoningEffort: "high" } },
},
headers: { "x-custom": "value" },
})
})
test("maps generic OpenAI-compatible providers to the native package", () => {
expect(
map("@ai-sdk/openai-compatible", {
apiKey: "secret",
baseURL: "https://provider.example/v1",
reasoningEffort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
apiKey: "secret",
baseURL: "https://provider.example/v1",
provider: "test-provider",
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
})
test("maps Bedrock provider and request options", () => {
expect(
map(
-96
View File
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { advance, drain } from "../lib/clock"
@@ -51,11 +50,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
@@ -65,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
}),
)
it.effect("applies remote permission defaults before explicit global and build rules", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
const entries = [
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"*.dev.vars": "deny",
"~/.local/share/opencode/mcp-auth.json": "deny",
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
},
external_directory: {
"*": "ask",
"~/.local/share/opencode/*": "deny",
},
},
}),
),
}),
new Document({
type: "document",
info: decode({
permissions: [{ action: "*", resource: "*", effect: "allow" }],
agents: {
build: {
permissions: [
{ action: "external_directory", resource: "*", effect: "allow" },
{
action: "external_directory",
resource: "~/.local/share/opencode/*",
effect: "deny",
},
{ action: "read", resource: "*.env", effect: "deny" },
],
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer(entries)),
)
const build = yield* agents.get(Agent.defaultID)
if (!build) throw new Error("expected configured build agent")
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
expect(build.permissions).toEqual([
...defaultPermissions(global),
{ action: "question", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "deny" },
{ action: "read", resource: "*.env.*", effect: "deny" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "read", resource: "*.dev.vars", effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "allow" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "read", resource: "*.env", effect: "deny" },
])
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
}),
)
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+25 -120
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("loads authenticated wellknown config before user configuration", () =>
it.live("loads authenticated wellknown config at highest priority", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -370,13 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const initial = yield* config.entries()
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["secret", "global", "project"])
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -384,13 +378,7 @@ describe("Config", () => {
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
const refreshed = yield* config.entries()
expect(Config.latest(refreshed, "shell")).toBe("project")
expect(
refreshed.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["next", "global", "project"])
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
}).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
)
@@ -399,96 +387,27 @@ describe("Config", () => {
),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
const details = entry.message[1]
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
})
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const malformed = path.join(tmp.path, "malformed.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), "null")
await fs.writeFile(path.join(project, "opencode.json"), "")
await fs.writeFile(malformed, '{ "credential": "file-secret"')
})
const integrationID = Integration.ID.make("https://invalid.example.com")
const entry: WellKnown.Entry = {
origin: "https://invalid.example.com",
integrationID,
manifest: { auth: { command: ["login"], env: "TOKEN" } },
}
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.die("unused Credential.all"),
list: () =>
Effect.succeed([
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
}),
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
),
deps: [],
})
const wellknownNode = makeGlobalNode({
service: WellKnown.Service,
layer: Layer.succeed(
WellKnown.Service,
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
}),
),
deps: [],
})
it.effect("detects v1 configuration from any v1-only top-level key", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
}),
)
yield* Config.Service.use((config) => config.entries()).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
file: malformed,
content: "",
}),
),
)
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
[
`${path.join(global, "opencode.json")}:$:invalid`,
`${path.join(project, "opencode.json")}:$:invalid`,
`${malformed}:$:invalid`,
"https://invalid.example.com:$:invalid",
"OPENCODE_CONFIG_CONTENT:$:invalid",
].toSorted(),
)
expect(JSON.stringify(output)).not.toContain("secret")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
Effect.sync(() => {
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}),
)
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
@@ -512,20 +431,6 @@ describe("Config", () => {
}),
)
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
@@ -611,12 +516,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
@@ -1,509 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Info } from "@opencode-ai/schema/config"
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
function normalized(input: unknown) {
const result = ConfigNormalize.normalize(input)
expect(result.type).toBe("normalized")
if (result.type !== "normalized") throw new Error("expected normalized config")
return result
}
function decoded(input: unknown) {
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
}
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
const result = structuredClone(input)
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
const mcp = result.mcp as Record<string, unknown>
const originallyEmpty = !Object.keys(mcp).length
for (const key of ["servers", "timeout"]) {
if (
typeof mcp[key] === "object" &&
mcp[key] !== null &&
!Array.isArray(mcp[key]) &&
!Object.keys(mcp[key]).length
)
delete mcp[key]
}
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
}
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
const compaction = result.compaction as Record<string, unknown>
const originallyEmpty = !Object.keys(compaction).length
if (
typeof compaction.keep === "object" &&
compaction.keep !== null &&
!Array.isArray(compaction.keep) &&
!Object.keys(compaction.keep).length
)
delete compaction.keep
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
}
return result
}
describe("ConfigNormalize", () => {
test("rejects every non-object root with one root diagnostic", () => {
for (const input of [null, [], "config", true, 1]) {
expect(ConfigNormalize.normalize(input)).toEqual({
type: "rejected",
diagnostics: [
{
kind: "invalid",
path: ["$"],
message: "rejected configuration because its root is not an object",
},
],
})
}
})
test("keeps unrelated native fields when a legacy field is present", () => {
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
expect(result.snapshots).toBe(false)
expect(result.agents?.reviewer?.system).toBe("Use V2")
})
test("canonicalizes transformed native values through decode then encode", () => {
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
const info = Schema.decodeUnknownSync(Info)(result.encoded)
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
})
test("preserves arbitrary JSON-round-tripped native configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(Info), (info) => {
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
const result = normalized(source)
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
)
}),
{ numRuns: 100 },
)
})
test("merges named maps by entry and gives valid native entries precedence", () => {
const result = normalized({
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
commands: { native: { template: "native" }, duplicate: { template: "new" } },
})
expect(result.encoded.references).toEqual({
legacy: { path: "../legacy" },
native: { path: "../native" },
duplicate: { path: "../new" },
})
expect(result.encoded.commands).toEqual({
legacy: { template: "legacy" },
native: { template: "native" },
duplicate: { template: "new" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["references", "duplicate"],
["commands", "duplicate"],
])
})
test("does not report canonical-equal duplicates as conflicts", () => {
const result = normalized({
snapshot: false,
snapshots: false,
reference: { docs: { path: "../docs" } },
references: { docs: { path: "../docs" } },
agent: { reviewer: { prompt: "same" } },
agents: { reviewer: { system: "same" } },
provider: { custom: { name: "same" } },
providers: { custom: { name: "same" } },
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
})
test("uses agent then mode then native agent precedence", () => {
const result = normalized({
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
})
expect(result.encoded.agents).toEqual({
reviewer: { system: "native" },
agentOnly: { system: "agent-only" },
modeOnly: { system: "mode-only", mode: "primary" },
nativeOnly: { system: "native-only" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["agents", "reviewer"],
["agents", "reviewer"],
])
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("migrates the legacy small model to the title agent", () => {
const result = normalized({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
})
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
},
})
expect(result.diagnostics).toEqual([])
})
test("omits an invalid legacy small model without exposing its value", () => {
const secret = "do-not-log-this-value"
const result = normalized({ small_model: secret })
expect(result.encoded.agents).toBeUndefined()
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("recovers malformed named entries and retains a valid legacy collision", () => {
const result = normalized({
command: { fallback: { template: "legacy" } },
commands: {
fallback: { template: 1 },
valid: { template: "native" },
invalid: { template: false },
},
providers: {
valid: { name: "Valid" },
invalid: { env: [1] },
},
})
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["commands", "fallback"],
["commands", "invalid"],
["providers", "invalid"],
])
})
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
const result = normalized({
provider: {
"azure-cognitive-services": { models: { deployment: {} } },
azure: { env: [1] },
},
})
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
"provider",
"azure",
])
})
test("preserves permission source order and appends native rules", () => {
expect(
normalized({
tools: { bash: true, write: false },
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
permissions: [{ action: "native", resource: "*", effect: "deny" }],
}).encoded.permissions,
).toEqual([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "custom", resource: "first", effect: "deny" },
{ action: "custom", resource: "second", effect: "ask" },
{ action: "subagent", resource: "*", effect: "allow" },
{ action: "native", resource: "*", effect: "deny" },
])
})
test("redacts permission resource keys from invalid diagnostics", () => {
const result = normalized({
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
})
expect(result.diagnostics).toEqual([
{
kind: "invalid",
path: ["permission", "bash", "0"],
message: "skipped malformed recognized value",
},
])
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
})
test("recovers list items for skills, plugins, instructions, and permissions", () => {
const result = normalized({
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
plugin: ["legacy", ["tuple", {}], [1, {}]],
plugins: ["native", { package: "object" }, { package: 1 }],
instructions: ["one", 2, "three"],
permissions: [
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "invalid" },
],
})
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
expect(result.encoded.plugins).toEqual([
"legacy",
{ package: "tuple", options: {} },
"native",
{ package: "object" },
])
expect(result.encoded.instructions).toEqual(["one", "three"])
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
})
test("omits malformed collection roots instead of synthesizing empty values", () => {
const result = normalized({
commands: [],
providers: "invalid",
references: false,
agents: 1,
plugins: {},
permissions: {},
instructions: {},
})
expect(result.encoded).toEqual({})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["references"],
["commands"],
["agents"],
["providers"],
["permissions"],
["plugins"],
["instructions"],
])
})
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
const invalid = normalized({
formatter: { prettier: { command: [1] } },
lsp: { typescript: { command: [1] } },
})
expect(invalid.encoded).not.toHaveProperty("formatter")
expect(invalid.encoded).not.toHaveProperty("lsp")
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["formatter", "prettier"],
["lsp", "typescript"],
])
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
})
test("combines legacy and native MCP servers and merges timeout leaves", () => {
const result = normalized({
experimental: { mcp_timeout: 5000 },
mcp: {
legacy: { type: "local", command: ["legacy"] },
duplicate: { type: "remote", url: "https://legacy.example.com" },
servers: {
native: { type: "local", command: ["native"] },
duplicate: { type: "remote", url: "https://native.example.com" },
invalid: { type: "local", command: [1] },
},
timeout: { startup: 1000, catalog: 6000 },
},
})
expect(result.encoded.mcp).toEqual({
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
servers: {
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
duplicate: { type: "remote", url: "https://native.example.com" },
native: { type: "local", command: ["native"] },
},
})
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
).toBe(true)
})
test("uses raw MCP discriminators for reserved server names", () => {
const result = normalized({
mcp: {
servers: { type: "local", command: ["reserved-servers"] },
timeout: { type: "remote", url: "https://reserved.example.com" },
},
})
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
})
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
expect(enabledOnly.encoded.mcp).toBeUndefined()
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["mcp", "servers"]],
["unsupported", ["mcp", "timeout"]],
])
})
test("merges bounded compaction leaves and omits unsupported leaves", () => {
const result = normalized({
compaction: {
auto: false,
preserve_recent_tokens: 1000,
keep: { tokens: 2000 },
reserved: 3000,
buffer: 4000,
tail_turns: 2,
prune: true,
},
})
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["compaction", "tail_turns"]],
["unsupported", ["compaction", "prune"]],
["conflict", ["compaction", "keep", "tokens"]],
["conflict", ["compaction", "buffer"]],
])
})
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
})
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
],
})
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
})
test("appends native policies after migrated provider policies", () => {
expect(
normalized({
enabled_providers: ["anthropic"],
disabled_providers: ["openai"],
experimental: {
subagent_depth: 0,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
subagent_depth: 0,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
{ action: "provider.use", resource: "custom", effect: "allow" },
],
})
})
test("reports unsupported legacy settings without including their values", () => {
const secret = "do-not-log-this-value"
const result = normalized({
logLevel: "DEBUG",
agent: { reviewer: { name: secret, prompt: "review" } },
provider: {
custom: {
id: secret,
whitelist: ["model"],
models: {
model: {
release_date: secret,
status: "active",
interleaved: true,
},
},
},
},
experimental: { openTelemetry: true },
})
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["logLevel"],
["agent", "reviewer", "name"],
["provider", "custom", "id"],
["provider", "custom", "whitelist"],
["provider", "custom", "models", "model", "release_date"],
["provider", "custom", "models", "model", "status"],
["provider", "custom", "models", "model", "interleaved"],
["experimental", "openTelemetry"],
])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
const result = normalized({
command: {
invalidModel: { template: "one", model: "invalid" },
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
missingModel: { template: "three", variant: "high" },
},
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
})
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
"invalidModel",
"invalidVariant",
"missingModel",
])
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["command", "invalidModel", "model"],
["command", "invalidVariant", "variant"],
["command", "missingModel", "variant"],
["agent", "invalid", "model"],
["agent", "invalid", "variant"],
])
})
test("invalid legacy provider overlays skip only that provider", () => {
const result = normalized({
provider: {
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
body: { options: { body: "not-an-object" } },
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
},
})
expect(result.encoded.providers).toEqual({
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["provider", "headers", "options", "headers"],
["provider", "body", "options", "body"],
])
})
test("preserves explicit false, zero, empty list, and empty map presence", () => {
const result = normalized({
snapshot: false,
autoshare: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded).toMatchObject({
snapshots: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded.share).toBeUndefined()
})
})
+2 -10
View File
@@ -237,11 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: {
chat: {
name: "First",
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
+7 -7
View File
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
target: target.absolute,
target: target.canonical,
resource: "hello.txt",
existed: true,
})
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
expect(result).toEqual({
operation: "write",
target: target.absolute,
target: target.canonical,
resource: "src/nested/hello.txt",
existed: false,
})
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
}).pipe(provide(directory)),
),
)
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
),
)
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
expect(result).toEqual({
operation: "write",
target: target.absolute,
target: target.canonical,
resource: target.resource,
existed: false,
})
@@ -109,7 +109,7 @@ describe("FileMutation", () => {
),
)
it.live("serializes concurrent writes to the same absolute target", () =>
it.live("serializes concurrent writes to the same canonical target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
),
)
it.live("allows distinct absolute targets to proceed independently", () =>
it.live("allows distinct canonical targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
+6 -1
View File
@@ -7,6 +7,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { ID, Info, Ref } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
@@ -65,9 +66,13 @@ const aisdk = Layer.mock(AISDK.Service, {
},
model: () => Effect.succeed(runtime),
})
const hooks = Layer.mock(PluginHooks.Service, {
register: () => Effect.die("unused"),
trigger: (_domain, _name, event) => Effect.succeed(event),
})
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk, hooks)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
+19 -16
View File
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
resource: "hello.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -50,8 +50,10 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const root = yield* Effect.promise(() => fs.realpath(directory))
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
canonical: path.join(root, "src", "new.txt"),
resource: "src/new.txt",
})
}).pipe(provide(directory)),
@@ -62,9 +64,9 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
canonical: path.join(root, "outside.txt"),
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
@@ -75,7 +77,7 @@ describe("LocationMutation", () => {
),
)
it.live("resolves a prospective target below an external symlink lexically", () =>
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
@@ -86,7 +88,7 @@ describe("LocationMutation", () => {
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -105,7 +107,7 @@ describe("LocationMutation", () => {
})
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
resource: "linked/new.txt",
})
}).pipe(provide(directory)),
@@ -118,7 +120,7 @@ describe("LocationMutation", () => {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
resource: "new.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -132,9 +134,9 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = outside
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
canonical: path.join(root, "new.txt"),
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
@@ -153,23 +155,24 @@ describe("LocationMutation", () => {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
expect(target.externalDirectory?.directory).toBe(root)
}).pipe(provide(directory)),
),
),
)
it.live("authorizes prospective external descendants at their lexical parent", () =>
it.live("anchors prospective external descendants at their stable existing directory", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath)
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(target.externalDirectory).toMatchObject({
directory: parent,
resource: path.join(parent, "*").replaceAll("\\", "/"),
directory: root,
resource: path.join(root, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
+54 -13
View File
@@ -131,6 +131,56 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes Cloudflare Workers AI through the generic OpenAI-compatible provider", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.resolveModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
providerID: Provider.ID.make("cloudflare-workers-ai"),
modelID: "@cf/meta/llama-3.1-8b-instruct",
settings: {
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
queryParams: { version: "preview" },
reasoningEffort: "high",
},
}),
undefined,
Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account/id" } }),
{
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
resolveProvider: (input) =>
Effect.succeed({
...input.settings,
baseURL: String(input.settings.baseURL).replace(
"${CLOUDFLARE_ACCOUNT_ID}",
encodeURIComponent(String(input.settings.accountId)),
),
}),
},
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://example.com",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(String(resolved.provider)).toBe("cloudflare-workers-ai")
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1")
expect(resolved.route.endpoint.query).toEqual({ version: "preview" })
expect(resolved.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
reasoning_effort: "high",
stream_options: { include_usage: true },
})
expect(prepared.body).not.toHaveProperty("accountId")
expect(headers.authorization).toBe("Bearer secret")
}),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -194,11 +244,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
apiKey: "settings-secret",
baseURL: "https://compatible.example/v1",
@@ -208,8 +254,7 @@ describe("ModelResolver", () => {
body: {},
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
const prepared = yield* compileRequest(request)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
@@ -221,10 +266,6 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
expect(resolved.compatibility?.requireFinishReason).toBe(false)
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
expect(prepared.body).not.toHaveProperty("max_tokens")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({})
}),
@@ -365,7 +406,7 @@ describe("ModelResolver", () => {
}),
)
it.effect("prefers stored credentials over configured auth", () =>
it.effect("keeps key credential metadata out of request bodies", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -385,7 +426,7 @@ describe("ModelResolver", () => {
})
expect(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
+3
View File
@@ -29,6 +29,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
aisdk: overrides.aisdk ?? {
hook: () => Effect.die("unused aisdk.hook"),
},
provider: overrides.provider ?? {
hook: () => Effect.die("unused provider.hook"),
},
catalog: overrides.catalog ?? {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
@@ -1,13 +1,13 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { Provider } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Credential } from "@opencode-ai/core/credential"
import { ID, Info } from "@opencode-ai/core/model"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,9 +15,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host)
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
})
function required<T>(value: T | undefined): T {
@@ -25,243 +23,130 @@ function required<T>(value: T | undefined): T {
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
function withEnv<A, E, R>(value: string | undefined, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
else process.env.CLOUDFLARE_ACCOUNT_ID = value
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
Effect.sync(() => {
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
}),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
modelID,
)
}
type CloudflareConfig = {
url: (input: { path: string; modelId: string }) => string
headers: () => Record<string, string> | Promise<Record<string, string>>
}
function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
}
function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.headers()
}
const providerID = Provider.ID.make("cloudflare-workers-ai")
describe("CloudflareWorkersAIPlugin", () => {
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
it.effect("resolves the account environment variable into the native endpoint", () =>
withEnv("account/id", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1" },
headers: { "User-Agent": expect.stringContaining("cloudflare-workers-ai") },
})
expect(provider).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
})
expect(sdk.sdk).toBeDefined()
}),
),
)
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
it.effect("resolves an account ID from provider settings", () =>
withEnv(undefined, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { accountId: "configured/account" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://proxy.example/v1" },
})
}),
),
)
it.effect("allows a configured baseURL without account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
})
expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
}),
),
)
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, accountId: "configured-acct" }
}),
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/ai/v1",
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
})
}),
),
)
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
it.effect("resolves an account ID from credential metadata before native routing", () =>
withEnv(undefined, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
const event = yield* (yield* PluginHooks.Service).trigger("provider", "resolve", {
model: Info.make({
id: ID.make("model"),
modelID: ID.make("@cf/model"),
providerID,
name: "Model",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
apiKey: "auth-key",
baseURL: "https://proxy.example/v1",
headers: { custom: "header" },
},
})
const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
expect(headers.authorization).toBe("Bearer env-key")
expect(headers.custom).toBe("header")
expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
}),
),
)
it.effect("expands account ID vars in endpoint URLs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "stored/account" } }),
settings: {
accountId: "stored/account",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
},
})
expect(cloudflareURL(result.sdk)).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
expect(event.settings.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/stored%2Faccount/ai/v1",
)
}),
),
)
it.effect("selects languageModel with the API model ID", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
modelID: Model.ID.make("@cf/api-model"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(result.language).toBeDefined()
expect(calls).toEqual(["languageModel:@cf/api-model"])
}),
it.effect("expands account placeholders and preserves configured endpoints", () =>
withEnv("env-account", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/env-account/ai/v1",
)
}),
),
)
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
it.effect("preserves a custom endpoint when an account ID is configured", () =>
withEnv(undefined, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: "https://proxy.example/v1" },
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { accountId: "configured-account", baseURL: "https://proxy.example/v1" }
}),
package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" },
})
expect(result.sdk).toBeUndefined()
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
}),
),
)
+1 -83
View File
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
SessionTransfer.node,
]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, projects],
@@ -748,77 +740,3 @@ describe("Session.create", () => {
}),
)
})
describe("SessionTransfer", () => {
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported" })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
type: "user",
text: "Imported message",
time: { created: DateTime.makeUnsafe(100) },
},
{
id: errorMessageID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "test_error", message: "Original error" },
time: { created: DateTime.makeUnsafe(101) },
},
],
},
location,
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
"compaction",
"user",
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
}),
)
it.effect("rejects an existing session ID without changing its transcript", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const existing = yield* session.create({ location, title: "Existing" })
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
expect(exit._tag).toBe("Failure")
expect((yield* session.get(existing.id)).title).toBe("Existing")
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
}),
)
})
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@@ -51,53 +50,22 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
malformedContent[64 * 1024] = 0x80
yield* files.writeFile(malformed, malformedContent)
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
}),
)
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
)
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
{ path: "escape", type: "symlink" },
{ path: "file.txt", type: "file" },
])
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
}),
)
+19 -18
View File
@@ -148,13 +148,13 @@ const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
const directory = path.dirname(absolute)
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
const directory = path.dirname(canonical)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
absolute,
canonical,
resource,
externalDirectory: external
? {
@@ -621,6 +621,10 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* Tool.Service
for (const [error, message] of [
[
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
"File is not valid UTF-8: invalid.txt",
],
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
[
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
@@ -717,19 +721,16 @@ describe("ReadTool", () => {
const registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
})
expect(result).toMatchObject({
status: "completed",
output: { entries: listResult.entries, truncated: true, next: 4 },
})
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
})
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
if (result.status !== "completed") return
expect(result.content).toEqual([
{
+2 -45
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
@@ -39,8 +39,6 @@ const providers = [
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -54,8 +52,6 @@ beforeEach(() => {
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
default: () =>
Effect.gen(function* () {
const stored = values.get("websearch:provider")
if (stored === false) return yield* new WebSearch.DisabledError()
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
}),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
const stored = values.get("websearch:provider")
if (queryBarrier && synchronizedQueries < 5) {
synchronizedQueries++
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
@@ -330,35 +316,6 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
queryBarrier = yield* Deferred.make<void>()
const registry = yield* Tool.Service
const results = yield* Effect.all(
Array.from({ length: 5 }, (_, index) =>
executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-concurrent-${index}`,
name: "websearch",
input: { query: `effect ${index}` },
},
}),
),
{ concurrency: "unbounded" },
)
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(values.get("websearch:provider")).toBe("exa")
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
+20 -13
View File
@@ -90,7 +90,13 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
writeToolNode,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
@@ -224,10 +230,7 @@ describe("WriteTool", () => {
const deduplicated = path.join(tmp.path, "deduplicated.txt")
formatFile = (target) =>
Effect.promise(async () => {
await fs.writeFile(
target,
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
)
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
return true
})
return Effect.promise(() =>
@@ -320,22 +323,24 @@ describe("WriteTool", () => {
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
const absoluteTarget = target
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]).toMatchObject({
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
resources: [
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
],
})
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled).toMatchObject({
status: "completed",
output: {
target: absoluteTarget,
resource: absoluteTarget.replaceAll("\\", "/"),
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([absoluteTarget])
expect(writes).toEqual([canonicalTarget])
}),
),
)
@@ -363,10 +368,12 @@ describe("WriteTool", () => {
),
Effect.andThen(
Effect.gen(function* () {
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
expect(assertions[0]).toMatchObject({
action: "external_directory",
resources: [path.join(nested, "*").replaceAll("\\", "/")],
save: [path.join(repo, "*").replaceAll("\\", "/")],
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
})
}),
),
@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { isSafeExternalUrl } from "./external-url"
describe("external URL policy", () => {
test.each(["https://opencode.ai", "http://127.0.0.1:4096", "mailto:hello@opencode.ai"])("allows %s", (url) =>
expect(isSafeExternalUrl(url)).toBe(true),
)
test.each(["file:///tmp/test", "javascript:alert(1)", "data:text/html,test", "vscode://file/tmp/test", "nope"])(
"rejects %s",
(url) => expect(isSafeExternalUrl(url)).toBe(false),
)
test("rejects non-string values", () => {
expect(isSafeExternalUrl(null)).toBe(false)
expect(isSafeExternalUrl({ toString: () => "https://opencode.ai" })).toBe(false)
})
})
@@ -1,6 +0,0 @@
const protocols = new Set(["http:", "https:", "mailto:"])
export function isSafeExternalUrl(value: unknown) {
if (typeof value !== "string" || !URL.canParse(value)) return false
return protocols.has(new URL(value).protocol)
}
-2
View File
@@ -8,7 +8,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { isSafeExternalUrl } from "./external-url"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
@@ -179,7 +178,6 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
if (!isSafeExternalUrl(url)) return
void shell.openExternal(url)
})
-5
View File
@@ -220,11 +220,6 @@ export function createMainWindow(id: string = randomUUID()) {
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
win.webContents.on("will-frame-navigate", (event) => {
if (isRendererUrl(event.url)) return
event.preventDefault()
})
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }))
loadWindow(win, "index.html")
wireZoom(win)
+2
View File
@@ -8,6 +8,7 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ProviderDomain } from "./provider.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -25,6 +26,7 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly plugin: PluginApi<unknown>
readonly provider: ProviderDomain
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
+15
View File
@@ -0,0 +1,15 @@
import type { Credential } from "@opencode-ai/schema/credential"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export interface ProviderHooks {
resolve: {
readonly model: Model.Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}
}
export interface ProviderDomain {
readonly hook: Hooks<ProviderHooks>
}
+2
View File
@@ -7,6 +7,7 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ProviderDomain } from "./provider.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -24,6 +25,7 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly plugin: PluginApi
readonly provider: ProviderDomain
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
+15
View File
@@ -0,0 +1,15 @@
import type { Credential } from "@opencode-ai/schema/credential"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export interface ProviderHooks {
resolve: {
readonly model: Model.Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}
}
export interface ProviderDomain {
readonly hook: Hooks<ProviderHooks>
}
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,5 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
@@ -164,36 +163,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.import", "/api/session/import", {
payload: Schema.Struct({
...SessionTransfer.Data.fields,
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: ConflictError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.import",
summary: "Import session",
description: "Import a projected session transcript at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
params: { sessionID: Session.ID },
query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionTransfer.Data }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.export",
summary: "Export session",
description: "Export a complete projected session transcript.",
}),
),
)
.add(
HttpApiEndpoint.get("session.active", "/api/session/active", {
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
+1 -1
View File
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
id,
name: Name.make(id),
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
mode: "all",
hidden: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath, optional } from "./schema.js"
import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(optional).annotate({
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(optional).annotate({
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(optional).annotate({
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(optional),
url: Schema.String.pipe(Schema.optional),
})
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(optional).annotate({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(optional).annotate({
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(optional).annotate({
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(optional).annotate({
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(optional).annotate({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(optional).annotate({
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(optional).annotate({
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(optional).annotate({
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, optional).annotate({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(optional).annotate({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(optional),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+4 -5
View File
@@ -1,7 +1,6 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+2 -3
View File
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(optional),
image: Image.pipe(Schema.optional),
}) {}
+1 -2
View File
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(optional),
variant: VariantID.pipe(Schema.optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+1 -2
View File
@@ -1,11 +1,10 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+24 -25
View File
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(optional),
}).pipe(Schema.optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(optional),
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: Capabilities.pipe(optional),
capabilities: Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}
+5 -6
View File
@@ -1,19 +1,18 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}
+3 -4
View File
@@ -1,16 +1,15 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(optional).annotate({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(optional).annotate({
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(optional).annotate({
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+1 -2
View File
@@ -1,8 +1,7 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, optional),
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
-1
View File
@@ -25,7 +25,6 @@ export { Vcs } from "./vcs.js"
export { SessionPending } from "./session-pending.js"
export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
export { Snapshot } from "./snapshot.js"
export { Shell } from "./shell.js"
export { Skill } from "./skill.js"
+23 -19
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(optional).annotate({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(optional).annotate({
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(optional).annotate({
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(optional).annotate({
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Schema.toTaggedUnion("status"),
)
-7
View File
@@ -47,16 +47,9 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.String,
]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
-11
View File
@@ -1,11 +0,0 @@
export * as SessionTransfer from "./session-transfer.js"
import { Schema } from "effect"
import { Session } from "./session.js"
import { SessionMessage } from "./session-message.js"
export interface Data extends Schema.Schema.Type<typeof Data> {}
export const Data = Schema.Struct({
info: Session.Info,
messages: Schema.Array(SessionMessage.Info),
}).annotate({ identifier: "SessionTransfer.Data" })
+1 -47
View File
@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
-19
View File
@@ -30,22 +30,3 @@ describe("Model.ReasoningField", () => {
expect(decode(field)).toBe(field)
})
})
describe("Model.Compatibility", () => {
test("decodes model compatibility overrides", () => {
const decode = Schema.decodeUnknownSync(Model.Compatibility)
expect(decode({})).toEqual({})
expect(
decode({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
}),
).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
})
})
-52
View File
@@ -1,5 +1,4 @@
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
return handlers
.handle(
@@ -88,56 +86,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.import",
Effect.fn(function* (ctx) {
return {
data: yield* transfer
.import({
data: { info: ctx.payload.info, messages: ctx.payload.messages },
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(
Effect.catchTag(
"SessionTransfer.ImportConflictError",
(error) =>
new ConflictError({
message: `Session already exists: ${error.sessionID}`,
resource: error.sessionID,
}),
),
),
}
}),
)
.handle(
"session.export",
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
),
}
}),
)
.handle(
"session.active",
Effect.fn(function* () {
-2
View File
@@ -16,7 +16,6 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
@@ -52,7 +51,6 @@ const applicationServices = LayerNode.group([
Job.node,
Project.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PermissionSaved.node,
+4 -20
View File
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+1 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
+7 -6
View File
@@ -88,7 +88,7 @@ export const settings: Setting[] = [
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
},
@@ -96,16 +96,17 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "cwd",
default: "global",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
{
title: "Layout",
title: "Vertical",
category: "Tabs",
path: ["tabs", "layout"],
default: "horizontal",
values: ["horizontal", "vertical"],
path: ["tabs", "vertical"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"],
},
{
@@ -6,7 +6,6 @@ import type {
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -446,19 +445,6 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{
bind: "c",
title: "Copy authorization details",
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
open
/>
)
}
@@ -574,14 +559,7 @@ function OAuthCode(props: {
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useTheme("elevated")
return (
@@ -605,18 +583,11 @@ function OAuthView(props: {
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
)
}
+16 -42
View File
@@ -15,24 +15,17 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
function Status(props: { status: McpServer["status"]; loading: boolean }) {
if (props.loading || props.status.status === "pending") {
return <>Connecting </>
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useTheme("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
if (props.status.status === "connected") {
return <span style={{ attributes: TextAttributes.BOLD }}>Connected </span>
}
if (props.status.status === "failed") {
return <>Failed !</>
}
if (props.status.status === "needs_auth") {
return <>Sign in required </>
}
return <>Disabled </>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -45,13 +38,6 @@ export function DialogMcp() {
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
const statusColor = (status: McpServer["status"]) => {
if (status.status === "connected") return theme.text.feedback.success.default
if (status.status === "failed") return theme.text.feedback.error.default
if (status.status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
}
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
@@ -67,29 +53,17 @@ export function DialogMcp() {
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => {
const pending = loadingMcp === server.name || server.status.status === "pending"
return {
value: server.name,
title: server.name,
footer: <Status status={server.status} loading={pending} />,
footerColor: pending ? theme.text.subdued : statusColor(server.status),
}
})
})
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
const toggleTitle = createMemo(() => {
const status = focusedServer()?.status.status
if (status === "connected") return "disconnect"
if (status === "failed") return "retry"
if (status === "needs_auth") return "sign in"
return "connect"
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
})
const focusedError = createMemo(() => {
const server = focusedServer()
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusError(server.status) : undefined
})
@@ -126,7 +100,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
actions={[
{
title: toggleTitle(),
title: "toggle",
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
+2 -6
View File
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
@@ -14,6 +14,7 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -45,6 +46,9 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>

Some files were not shown because too many files have changed in this diff Show More