mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 51cef27579 |
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
if (s?.status === "failed") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -426,8 +426,7 @@ 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" || status() === "needs_client_registration",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,7 +35,6 @@ 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)
|
||||
})
|
||||
|
||||
@@ -48,7 +47,6 @@ 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" || status === "needs_client_registration")
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "Show resolved configuration" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
@@ -137,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: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -22,6 +22,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
|
||||
@@ -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,7 +34,6 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -45,8 +44,6 @@ 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:
|
||||
|
||||
@@ -10,7 +10,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: serverURL,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -22,6 +22,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
config: () => import("./commands/handlers/debug/config"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
@@ -36,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"),
|
||||
|
||||
@@ -42,9 +42,10 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
const options = yield* ServiceConfig.options()
|
||||
const mismatch = args.mismatch ?? "ignore"
|
||||
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
|
||||
return {
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
|
||||
service: managedService(options),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
|
||||
}
|
||||
})
|
||||
|
||||
export const options = Effect.fnUntraced(function* () {
|
||||
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
|
||||
const { file, legacyRegistrationFiles } = yield* paths
|
||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||
return {
|
||||
file,
|
||||
version: OPENCODE_VERSION,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, 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"
|
||||
|
||||
describe("debug config command", () => {
|
||||
test("is included in troubleshooting help", async () => {
|
||||
const [debug, config] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "config", "--help"])])
|
||||
|
||||
expect(debug.exitCode).toBe(0)
|
||||
expect(debug.stdout).toContain("config")
|
||||
expect(debug.stdout).toContain("Show resolved configuration")
|
||||
expect(config.exitCode).toBe(0)
|
||||
expect(config.stdout).toContain("opencode debug config [flags]")
|
||||
})
|
||||
|
||||
test("prints config entries from the invoking directory without reordering permissions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-config-"))
|
||||
const project = path.join(import.meta.dir, "..")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const entries = [
|
||||
{
|
||||
type: "document",
|
||||
path: path.join(project, "opencode.json"),
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file", path: path.join(project, "opencode.json") },
|
||||
]
|
||||
let requested: URL | undefined
|
||||
const authorization: Array<string | null> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
requested = url
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, password: "secret" }),
|
||||
)
|
||||
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
|
||||
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual(entries)
|
||||
expect(requested?.pathname).toBe("/api/config")
|
||||
expect(requested?.searchParams.get("location[directory]")).toBe(project)
|
||||
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
|
||||
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -55,3 +55,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("service options only require a matching version when requested", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
||||
|
||||
try {
|
||||
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
|
||||
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -48,6 +49,7 @@ const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmi
|
||||
const effectTypeReferences = [
|
||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
|
||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Config } from "@opencode-ai/schema/config"
|
||||
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
@@ -126,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
|
||||
@@ -183,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
|
||||
@@ -198,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
|
||||
@@ -219,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
|
||||
@@ -861,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>
|
||||
@@ -1609,6 +1596,16 @@ export interface WebsearchApi<E = never> {
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
@@ -1639,4 +1636,5 @@ export interface AppApi<E = never> {
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
readonly config: ConfigApi<E>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -224,6 +220,8 @@ import type {
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -319,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),
|
||||
),
|
||||
@@ -331,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),
|
||||
),
|
||||
@@ -355,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: {
|
||||
@@ -414,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: {
|
||||
@@ -435,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: {
|
||||
@@ -461,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"] },
|
||||
@@ -493,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),
|
||||
),
|
||||
@@ -513,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"] },
|
||||
@@ -563,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),
|
||||
@@ -584,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) =>
|
||||
@@ -1265,6 +1243,13 @@ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
server: adaptGroup1(raw["server.server"]),
|
||||
@@ -1295,6 +1280,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
@@ -20,6 +21,7 @@ export type {
|
||||
} from "./api.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Config } from "@opencode-ai/schema/config"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
@@ -53,6 +53,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -82,6 +83,18 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const registration = yield* registered(options.file, true)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
timeouts = {
|
||||
info,
|
||||
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -182,6 +195,10 @@ type LocalService = {
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
return (yield* probeResult(info, allowLegacy)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -189,39 +206,53 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined) return undefined
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && health.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: yield* probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, allowLegacy)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
@@ -249,6 +280,19 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = yield* requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -2,6 +2,7 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type ModelApi = Client["model"]
|
||||
|
||||
@@ -15,10 +15,6 @@ import type {
|
||||
SessionListOutput,
|
||||
SessionCreateInput,
|
||||
SessionCreateOutput,
|
||||
SessionImportInput,
|
||||
SessionImportOutput,
|
||||
SessionExportInput,
|
||||
SessionExportOutput,
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
@@ -220,6 +216,8 @@ import type {
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -480,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 }>(
|
||||
{
|
||||
@@ -1839,6 +1813,20 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
config: {
|
||||
get: (input?: ConfigGetInput, requestOptions?: RequestOptions) =>
|
||||
request<ConfigGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/config`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
|
||||
@@ -34,6 +34,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -62,6 +63,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count:
|
||||
timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
@@ -145,6 +159,10 @@ type LocalService = {
|
||||
}
|
||||
|
||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||
return (await probeResult(info, allowLegacy)).service
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -152,30 +170,48 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (body.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && body.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true }
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: await probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(await probeResult(info, allowLegacy)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
@@ -209,6 +245,18 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = await requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -10,6 +11,7 @@ const Client = await import("../src/effect")
|
||||
|
||||
test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Agent).toBe(Agent)
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,10 @@ const server = Bun.serve({
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "hanging") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
|
||||
@@ -70,6 +70,32 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(existing)
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
@@ -33,6 +33,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -50,6 +51,34 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
let request: Request | undefined
|
||||
const entries = [
|
||||
{
|
||||
type: "document" as const,
|
||||
path: "/tmp/project/opencode.json",
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" as const },
|
||||
{ action: "shell", resource: "git status", effect: "allow" as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file" as const, path: "/tmp/project/opencode.json" },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.config.get({ location: { directory: "/tmp/project" } })).toEqual(entries)
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -70,6 +70,30 @@ test("reports a failed registered service without spawning", async () => {
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
+56
-164
@@ -5,8 +5,16 @@ import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory,
|
||||
Document,
|
||||
File,
|
||||
Info,
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
@@ -15,141 +23,10 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigWarming } from "./config/warming"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { ConfigNormalize } from "./config/normalize"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
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(Schema.optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
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(Schema.optional),
|
||||
})
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
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(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
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(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
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(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(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
type: Schema.Literal("directory"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
|
||||
type: Schema.Literal("claude"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
@@ -215,24 +92,43 @@ 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 decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
||||
const parseInfo = (text: string) => {
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
return Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: decodeInfo(input),
|
||||
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 (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) return
|
||||
if (text === undefined) return
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
const info = parseInfo(substituted)
|
||||
const info = yield* parseInfo(substituted, filepath)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
@@ -263,7 +159,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
@@ -296,21 +192,17 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...((yield* fs.isDir(globalClaudeDirectory))
|
||||
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".claude")
|
||||
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...((yield* fs.isDir(globalAgentsDirectory))
|
||||
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".agents")
|
||||
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
@@ -344,14 +236,14 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
Effect.orDie,
|
||||
)
|
||||
: []
|
||||
const content = options?.content
|
||||
const content = options?.content !== undefined
|
||||
? yield* ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
dir: location.directory,
|
||||
text: options.content,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
@@ -359,13 +251,13 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...supplementary.slice(1).flat(),
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
@@ -408,7 +300,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { ConfigModel } from "./model"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
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(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),
|
||||
}) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
export * as ConfigCompaction from "./compaction"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
export * as ConfigExperimental from "./experimental"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { ConfigPolicy } from "./policy"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
}) {}
|
||||
@@ -1,15 +0,0 @@
|
||||
export * as ConfigMedia from "./media"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
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(Schema.optional),
|
||||
}) {}
|
||||
@@ -0,0 +1,796 @@
|
||||
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", "small_model", "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 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 })
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
@@ -24,7 +25,7 @@ const legacySources = [
|
||||
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
@@ -63,13 +64,13 @@ export const Plugin = define({
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
documents.filter((document): document is Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Config.Document[] }
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
@@ -139,7 +140,7 @@ export const Plugin = define({
|
||||
|
||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||
// check: directory-level events such as renames carry no per-file paths.
|
||||
function isAgentSource(entries: Config.Entry[], file: string) {
|
||||
function isAgentSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -208,5 +209,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
||||
return new Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Command } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
@@ -27,7 +28,7 @@ export const Plugin = define({
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
@@ -75,7 +76,7 @@ const sourceDirectories = ["command", "commands"] as const
|
||||
|
||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||
// directory-level events such as renames carry no per-file paths.
|
||||
function isCommandSource(entries: Config.Entry[], file: string) {
|
||||
function isCommandSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigPolicyPlugin from "./policy"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Wildcard } from "../../util/wildcard"
|
||||
@@ -13,7 +14,7 @@ export const Plugin = define({
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
for (const record of catalog.provider.list()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
@@ -107,8 +108,8 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
||||
function configuredProviders(entries: readonly Entry[]) {
|
||||
return entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -19,7 +20,7 @@ export const Plugin = define({
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
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")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
||||
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(Schema.optional),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
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(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(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
}).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(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,9 +0,0 @@
|
||||
export * as ConfigToolOutput from "./tool-output"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * as ConfigWatcher from "./watcher"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
@@ -3,6 +3,7 @@ export * as LocationWatcher from "./location-watcher"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -41,7 +42,7 @@ const layer = Layer.effect(
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
|
||||
@@ -3,11 +3,12 @@ export * as MCP from "./index"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Config } from "../config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { Bus } from "../bus"
|
||||
import { Form } from "../form"
|
||||
@@ -178,7 +179,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprot
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: sessionHook,
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
|
||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -140,14 +141,6 @@ const oauth = (app: App.Info) => ({
|
||||
}),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -269,7 +262,7 @@ export const GithubCopilotPlugin = define({
|
||||
return
|
||||
}
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||
evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -9,7 +10,6 @@ import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigPlugin } from "../config/plugin"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -83,15 +83,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
@@ -208,7 +208,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Config.Entry[], file: string) {
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -243,7 +243,7 @@ const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Config.Entry[],
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -19,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_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.
|
||||
@@ -148,9 +149,9 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const settings = (documents: readonly Config.Entry[]) => {
|
||||
const settings = (documents: readonly Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigReference } from "../../config/reference"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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"
|
||||
@@ -10,80 +13,52 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { Provider } from "../../provider"
|
||||
import { Model } from "../../model"
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
$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] },
|
||||
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),
|
||||
}),
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
@@ -132,7 +107,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.
|
||||
function normalizeAction(action: string) {
|
||||
export function normalizeAction(action: string) {
|
||||
if (action === "write" || action === "patch") return "edit"
|
||||
if (action === "task") return "subagent"
|
||||
if (action === "bash") return "shell"
|
||||
@@ -154,21 +129,25 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
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),
|
||||
}
|
||||
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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
@@ -205,7 +184,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||
}
|
||||
|
||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local")
|
||||
return {
|
||||
@@ -244,7 +223,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
)
|
||||
}
|
||||
|
||||
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
export 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)
|
||||
@@ -256,7 +235,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 } : options.settings,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
models:
|
||||
@@ -300,8 +279,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||
function providerID(input: string) {
|
||||
// Rename these only while migrating unambiguous V1 fields.
|
||||
export function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
return input
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -19,7 +20,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
@@ -71,7 +72,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
||||
@@ -92,7 +93,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
@@ -153,7 +154,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -173,7 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -218,7 +219,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
@@ -276,7 +277,7 @@ Use native v2 fields.`,
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
|
||||
}),
|
||||
@@ -425,7 +426,7 @@ Use native v2 fields.`,
|
||||
})
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
@@ -522,7 +523,7 @@ function loadHomePermissions(home: string) {
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode(
|
||||
ConfigMigrateV1.migrate({
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
@@ -35,7 +35,7 @@ const it = testEffect(
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
@@ -63,7 +63,7 @@ Review files`,
|
||||
|
||||
const command = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const update = yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
const update = yield* bus.publish(Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -77,11 +77,11 @@ Review files`,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { template: "Inline review" } } }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -333,7 +333,7 @@ function watchReady(config: Config.Interface, directory: string) {
|
||||
}
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigModel } from "@opencode-ai/schema/config/model"
|
||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -161,7 +161,7 @@ describe("Config", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const changed = yield* bus
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
@@ -234,14 +234,14 @@ describe("Config", () => {
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
const removed = yield* bus
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => fs.rm(file))
|
||||
yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
|
||||
|
||||
const recreated = yield* bus
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
|
||||
yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
|
||||
@@ -273,7 +273,7 @@ describe("Config", () => {
|
||||
const test = yield* Config.Test
|
||||
expect(yield* config.entries()).toEqual([])
|
||||
|
||||
const entry = new Config.Document({ type: "document", info: new Config.Info({}) })
|
||||
const entry = new Document({ type: "document", info: new Info({}) })
|
||||
yield* test.setEntries([entry])
|
||||
expect(yield* config.entries()).toEqual([entry])
|
||||
|
||||
@@ -289,16 +289,16 @@ describe("Config", () => {
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({}) }),
|
||||
new Config.Document({
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||
it.live("loads authenticated wellknown config below project config", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
@@ -370,15 +370,15 @@ describe("Config", () => {
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
|
||||
const updated = yield* bus
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
|
||||
}).pipe(
|
||||
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||
)
|
||||
@@ -387,27 +387,96 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
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 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)
|
||||
}),
|
||||
)
|
||||
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("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
@@ -418,7 +487,7 @@ describe("Config", () => {
|
||||
Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
@@ -516,12 +585,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()
|
||||
}),
|
||||
)
|
||||
@@ -661,13 +730,45 @@ describe("Config", () => {
|
||||
const entries = yield* config.entries()
|
||||
|
||||
expect(entries).toEqual([
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
const project = path.join(home, "project")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.mkdir(path.join(home, ".claude"), { recursive: true }),
|
||||
fs.mkdir(path.join(home, ".agents"), { recursive: true }),
|
||||
fs.mkdir(project, { recursive: true }),
|
||||
]),
|
||||
)
|
||||
const entries = yield* Config.Service.use((config) => config.entries()).pipe(
|
||||
Effect.provide(testLayer(project, global)),
|
||||
)
|
||||
|
||||
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(path.join(home, ".claude")),
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(path.join(home, ".agents")),
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -729,7 +830,7 @@ describe("Config", () => {
|
||||
expect(documents).toHaveLength(2)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Document)
|
||||
expect(documents[0]).toBeInstanceOf(Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
@@ -1177,7 +1278,7 @@ describe("Config", () => {
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Info)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.default_agent).toBe("reviewer")
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { ConfigModel } from "@opencode-ai/schema/config/model"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
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("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",
|
||||
small_model: secret,
|
||||
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"],
|
||||
["small_model"],
|
||||
["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()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
|
||||
@@ -12,10 +12,10 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
experimental: {
|
||||
@@ -24,7 +24,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
@@ -78,7 +78,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
|
||||
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
@@ -14,7 +15,7 @@ import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
@@ -46,7 +47,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
@@ -55,7 +56,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -90,7 +91,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
})
|
||||
})
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -129,7 +130,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -178,7 +179,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -189,7 +190,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -223,7 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
@@ -255,7 +256,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
@@ -289,7 +290,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
@@ -22,7 +22,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
@@ -54,7 +54,7 @@ describe("config plugin reloads", () => {
|
||||
|
||||
yield* test.setEntries([config("second")])
|
||||
yield* Effect.yieldNow
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
@@ -83,7 +83,7 @@ describe("config plugin reloads", () => {
|
||||
})
|
||||
|
||||
function config(name: string) {
|
||||
return new Config.Document({
|
||||
return new Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
@@ -43,10 +44,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Config.Document({
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Schema } from "effect"
|
||||
import { Config } from "../../src/config"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("config warming", () => {
|
||||
test("accepts boolean enablement", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
@@ -13,16 +14,16 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Config.Info.Encoded
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -162,9 +163,9 @@ function resourceMcpLayer(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Config.testLayer([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: {
|
||||
resources:
|
||||
|
||||
@@ -151,6 +151,9 @@ describe("ModelResolver", () => {
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bodies: string[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
request.headers.set("x-hook", "promise")
|
||||
await next(request)
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-response`)
|
||||
})
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) =>
|
||||
Effect.promise(() => input.text()).pipe(
|
||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
),
|
||||
)
|
||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||
const request = yield* hooks.trigger("session", "http.request", {
|
||||
...context,
|
||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||
})
|
||||
const response = yield* hooks.trigger("session", "http.response", {
|
||||
...context,
|
||||
request: request.request,
|
||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||
})
|
||||
|
||||
expect(bodies).toEqual(["payload", "payload"])
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
)
|
||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
})
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
)
|
||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -140,7 +126,7 @@ describe("OpenAIPlugin", () => {
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -149,14 +135,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 272_000,
|
||||
context: 400_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -213,7 +214,7 @@ const configuredIt = testEffect(
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell
|
||||
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
|
||||
? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -255,6 +255,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
describe("SessionModelRequest HTTP bridge", () => {
|
||||
const bodies: Uint8Array[] = []
|
||||
const methods: string[] = []
|
||||
const headers: Array<string | undefined> = []
|
||||
const response = [
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
@@ -268,6 +269,7 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
methods.push(request.method)
|
||||
bodies.push(request.body.body.slice())
|
||||
headers.push(request.headers["x-hook"])
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||
@@ -275,14 +277,16 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const retryIt = testEffect(
|
||||
const httpIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||
Effect.gen(function* () {
|
||||
bodies.length = 0
|
||||
methods.length = 0
|
||||
headers.length = 0
|
||||
const seen: string[] = []
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -297,13 +301,20 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
yield* pluginHost.session.hook("http", (event) =>
|
||||
event.use((request, next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||
return yield* next(request)
|
||||
}),
|
||||
),
|
||||
yield* pluginHost.session.hook("http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("request")
|
||||
event.request.headers.set("x-hook", "effect")
|
||||
}),
|
||||
)
|
||||
yield* pluginHost.session.hook("http.response", (event) =>
|
||||
Effect.gen(function* () {
|
||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||
event.response = new Response(
|
||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||
event.response,
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
@@ -331,10 +342,15 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(methods).toEqual(["POST"])
|
||||
expect(headers).toEqual(["effect"])
|
||||
expect(seen).toEqual(["request", "response:200:effect"])
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Hooked!" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,9 +49,10 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionPendingTable,
|
||||
@@ -228,7 +229,7 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
|
||||
)
|
||||
@@ -334,9 +335,9 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
compaction: new ConfigCompaction.Info({
|
||||
buffer: 3_000,
|
||||
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
|
||||
|
||||
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -429,9 +430,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
@@ -472,9 +473,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
@@ -511,9 +512,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
|
||||
@@ -44,11 +44,12 @@ export type SQLiteEffectSelectPrepare<
|
||||
TEffectHKT
|
||||
>
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
||||
export class SQLiteEffectSelectBuilder<
|
||||
TSelection extends SelectedFields | undefined,
|
||||
TRunResult,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TBuilderMode extends "db" | "qb" = "db",
|
||||
out TSelection extends SelectedFields | undefined,
|
||||
out TRunResult,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TBuilderMode extends "db" | "qb" = "db",
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||
|
||||
|
||||
@@ -303,10 +303,11 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
||||
export abstract class SQLiteEffectSession<
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TRunResult = unknown,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TRunResult = unknown,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
@@ -404,9 +405,9 @@ export abstract class SQLiteEffectSession<
|
||||
}
|
||||
|
||||
export abstract class SQLiteEffectTransaction<
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
out TEffectHKT extends QueryEffectHKTBase,
|
||||
out TRunResult,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttp {
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
request: Request
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly http: SessionHttp
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttp {
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
request: Request
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly http: SessionHttp
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
+1292
-345
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
@@ -53,6 +54,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
@@ -175,6 +177,7 @@ const makeApiFromGroup = <
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.add(ConfigGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -62,6 +62,7 @@ export const groupNames = {
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
"server.vcs": "vcs",
|
||||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("config.get", "/api/config", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(Config.Entry),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.get",
|
||||
summary: "Get configuration",
|
||||
description:
|
||||
"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "config", description: "Location-scoped configuration routes." }))
|
||||
@@ -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,6 +1,142 @@
|
||||
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 { ConfigAgent } from "./config/agent.js"
|
||||
import { ConfigMedia } from "./config/media.js"
|
||||
import { ConfigCompaction } from "./config/compaction.js"
|
||||
import { ConfigCommand } from "./config/command.js"
|
||||
import { ConfigExperimental } from "./config/experimental.js"
|
||||
import { ConfigFormatter } from "./config/formatter.js"
|
||||
import { ConfigLSP } from "./config/lsp.js"
|
||||
import { ConfigMCP } from "./config/mcp.js"
|
||||
import { ConfigModel } from "./config/model.js"
|
||||
import { ConfigPlugin } from "./config/plugin.js"
|
||||
import { ConfigProvider } from "./config/provider.js"
|
||||
import { ConfigReference } from "./config/reference.js"
|
||||
import { ConfigWebSearch } from "./config/websearch.js"
|
||||
import { ConfigToolOutput } from "./config/tool-output.js"
|
||||
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({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(optional),
|
||||
})
|
||||
.pipe(optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(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),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
type: Schema.Literal("directory"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
|
||||
type: Schema.Literal("claude"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Document, Directory, File, AgentsDirectory, ClaudeDirectory]).annotate({
|
||||
identifier: "Config.Entry",
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "config.updated",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export * as ConfigAgent from "./agent.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { optional, 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),
|
||||
}) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
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),
|
||||
}) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * as ConfigCompaction from "./compaction.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
auto: Schema.Boolean.pipe(optional),
|
||||
keep: Keep.pipe(optional),
|
||||
buffer: NonNegativeInt.pipe(optional),
|
||||
}) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * as ConfigExperimental from "./experimental.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
import { ConfigPolicy } from "./policy.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
}) {}
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
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(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),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
export * as ConfigLSP from "./lsp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
@@ -8,10 +9,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, 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),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Disabled, Server])
|
||||
@@ -1,10 +1,9 @@
|
||||
export * as ConfigMCP from "./mcp"
|
||||
export * as ConfigMCP from "./mcp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Mcp } from "../mcp.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in
|
||||
// @opencode-ai/schema and is re-exported here.
|
||||
export const Timeout = Mcp.TimeoutConfig
|
||||
export type Timeout = Mcp.TimeoutConfig
|
||||
export const Local = Mcp.LocalConfig
|
||||
@@ -16,6 +15,6 @@ export type Remote = Mcp.RemoteConfig
|
||||
export const Server = Mcp.ServerConfig
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.MCP")({
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
timeout: Timeout.pipe(optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(optional),
|
||||
}) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * as ConfigMedia from "./media.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional, 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),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(optional),
|
||||
}) {}
|
||||
@@ -1,8 +1,9 @@
|
||||
export * as ConfigModel from "./model"
|
||||
export * as ConfigModel from "./model.js"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
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(/^[^#]+$/))
|
||||
@@ -11,7 +12,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
|
||||
const Explicit = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
model: ModelID,
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
variant: VariantID.pipe(optional),
|
||||
})
|
||||
|
||||
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user