Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton d3bc3a61dc fix(core): import credentials from previous channel database 2026-08-06 16:11:56 -04:00
69 changed files with 5450 additions and 6475 deletions
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
-26
View File
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "List all available models",
params: ServerParams,
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
...ServerParams,
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to export to stdout"),
Flag.optional,
),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
),
},
}),
Spec.make("import", {
description: "Import session data from a JSON file or URL",
params: {
...ServerParams,
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
directory: Flag.string("directory").pipe(
Flag.withDescription("Directory in which to import the session"),
Flag.optional,
),
},
}),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -1,140 +0,0 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}
@@ -1,60 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Session } from "@opencode-ai/schema/session"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.import,
Effect.fn("cli.import")(function* (input) {
const text = yield* Effect.tryPromise({
try: () =>
input.file.startsWith("http://") || input.file.startsWith("https://")
? fetch(input.file).then((response) => {
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
return response.text()
})
: Bun.file(input.file).text(),
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const location = yield* Effect.promise(() =>
client.location.get({
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/session/import", server.endpoint.url), {
method: "POST",
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
body: JSON.stringify({
...encoded,
location: { directory: location.directory, workspaceID: location.workspaceID },
}),
}),
)
if (response.status === 409) {
process.stderr.write(`Session already exists${EOL}`)
return
}
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
)(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}),
)
-2
View File
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/plugin/list"),
},
models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
-210
View File
@@ -1,210 +0,0 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import { writeExport } from "../src/commands/handlers/export"
const info = {
id: "ses_export_test",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Exported session",
location: { directory: "/project" },
}
const transfer = {
info,
messages: [
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
],
}
const sanitizedTransfer = {
info: {
...info,
title: "[redacted:session-title:ses_export_test]",
location: { directory: "/[redacted:session-directory:ses_export_test]" },
},
messages: [
{
id: "msg_first",
type: "user",
text: "[redacted:text:msg_first]",
time: { created: 1 },
},
{
id: "msg_second",
type: "user",
text: "[redacted:text:msg_second]",
time: { created: 2 },
},
],
}
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
stdin: stdin === undefined ? undefined : new Blob([stdin]),
stdout: "pipe",
stderr: "pipe",
})
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
}
test("export is raw by default and supports explicit sanitization", async () => {
const sanitization: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
const exported = JSON.parse(stdout)
expect(exitCode).toBe(0)
expect(exported).toEqual(transfer)
const [sanitized, , sanitizedExitCode] = await run([
"export",
"-s",
info.id,
"--sanitize",
"--server",
server.url.toString(),
])
expect(sanitizedExitCode).toBe(0)
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
expect(sanitization).toEqual(["false", "true"])
} finally {
await server.stop(true)
}
}, 15_000)
test("export reports an empty session list without a stack trace", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
project: { id: "global", directory: "/project", canonical: "/project" },
})
}
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`No sessions found${os.EOL}`)
} finally {
await server.stop(true)
}
})
test("interactive export writes a temporary JSON file", async () => {
const output = await writeExport(transfer, info.id, false)
const file = output.trim()
try {
expect(path.dirname(file)).toBe(os.tmpdir())
expect(await Bun.file(file).json()).toEqual(transfer)
} finally {
await fs.rm(file, { force: true })
}
})
test("import validates a file and sends it to the resolved location", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
let imported: unknown
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") {
imported = await request.json()
return Response.json({ data: { ...info, location: { directory: root } } })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run([
"import",
file,
"--directory",
root,
"--server",
server.url.toString(),
])
expect(exitCode).toBe(0)
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
expect(imported).toEqual({ ...transfer, location: { directory: root } })
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
test("import reports an existing session without a stack trace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`Session already exists${os.EOL}`)
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
+83 -97
View File
@@ -127,54 +127,42 @@ export type Endpoint5_1Input = {
export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = {
readonly info: Session.Info
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly location?: Location.Ref | undefined
}
export type Endpoint5_2Output = Session.Info
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
export type Endpoint5_3Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
export type Endpoint5_4Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_5Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_7Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_9Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_10Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_15Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_17Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_19Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_25Input = {
export type Endpoint5_23Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_25Output = void
export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_26Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_28Output =
export type Endpoint5_26Output =
| (
| {
readonly id: Event.ID
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
+83 -107
View File
@@ -21,10 +21,10 @@ import type {
Endpoint5_0Output,
Endpoint5_1Input,
Endpoint5_1Output,
Endpoint5_2Input,
Endpoint5_2Output,
Endpoint5_3Input,
Endpoint5_3Output,
Endpoint5_4Input,
Endpoint5_4Output,
Endpoint5_5Input,
Endpoint5_5Output,
@@ -76,10 +76,6 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
),
)
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
preserveEffect<Endpoint5_2Output>()(
raw["session.import"]({
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
}).pipe(
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -333,23 +327,20 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
preserveEffect<Endpoint5_3Output>()(
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
preserveEffect<Endpoint5_4Output>()(
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect<Endpoint5_5Output>()(
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -357,48 +348,35 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()(
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -416,8 +394,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -437,16 +415,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -463,29 +441,29 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -495,19 +473,35 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -515,45 +509,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_28Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -565,18 +543,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -586,32 +564,30 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
import: Endpoint5_2(raw),
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(raw),
remove: Endpoint5_6(raw),
fork: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw),
rename: Endpoint5_10(raw),
move: Endpoint5_11(raw),
prompt: Endpoint5_12(raw),
command: Endpoint5_13(raw),
skill: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw),
shell: Endpoint5_16(raw),
compact: Endpoint5_17(raw),
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_27(raw),
log: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
active: Endpoint5_2(raw),
get: Endpoint5_3(raw),
remove: Endpoint5_4(raw),
fork: Endpoint5_5(raw),
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(raw),
move: Endpoint5_9(raw),
prompt: Endpoint5_10(raw),
command: Endpoint5_11(raw),
skill: Endpoint5_12(raw),
synthetic: Endpoint5_13(raw),
shell: Endpoint5_14(raw),
compact: Endpoint5_15(raw),
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -15,10 +15,6 @@ import type {
SessionListOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
SessionImportOutput,
SessionExportInput,
SessionExportOutput,
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionImportOutput }>(
{
method: "POST",
path: `/api/session/import`,
body: { info: input["info"], messages: input["messages"], location: input["location"] },
successStatus: 200,
declaredStatuses: [409, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionExportOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
query: { sanitize: input["sanitize"] },
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionActiveOutput }>(
{
File diff suppressed because it is too large Load Diff
+16 -34
View File
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable"
import { ConfigNormalize } from "./config/normalize"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { WellKnown } from "./wellknown"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
const parseInfo = (text: string) => {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
if (errors.length) return
return Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
)
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
}
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
const info = parseInfo(substituted)
if (!info) return
return new Document({ type: "document", path: filepath, info })
})
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map(parseInfo),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
Effect.orDie,
)
: []
const content = options?.content !== undefined
const content = options?.content
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map(parseInfo),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
-796
View File
@@ -1,796 +0,0 @@
export * as ConfigNormalize from "./normalize"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent"
import { ConfigAttachmentV1 } from "../v1/config/attachment"
import { ConfigCommandV1 } from "../v1/config/command"
import { ConfigMCPV1 } from "../v1/config/mcp"
import { ConfigPermissionV1 } from "../v1/config/permission"
import { ConfigPluginV1 } from "../v1/config/plugin"
import { ConfigProviderV1 } from "../v1/config/provider"
import { ConfigMigrateV1 } from "../v1/config/migrate"
import { PositiveInt } from "../schema"
export interface Diagnostic {
readonly kind: "conflict" | "invalid" | "unsupported"
readonly path: readonly string[]
readonly message: string
}
export type Result =
| {
readonly type: "normalized"
readonly encoded: Readonly<Record<string, unknown>>
readonly diagnostics: readonly Diagnostic[]
}
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const unsupportedTopLevel = ["logLevel", "server", "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
View File
@@ -42,5 +42,6 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806200000_import_next_credentials"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,89 @@
import path from "node:path"
import { existsSync } from "node:fs"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Global } from "@opencode-ai/util/global"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(Credential.Value)
export default {
id: "20260806200000_import_next_credentials",
up(tx) {
return importNextCredentials(tx, path.join(Global.Path.data, "opencode-next.db"))
},
} satisfies DatabaseMigration.Migration
/**
* The next channel stored credentials in its own `opencode-next.db` before the
* channel databases were consolidated into `opencode.db`. The legacy import
* only reads V1 `auth.json`, so a credential that existed only in the previous
* channel database was silently dropped. Copy those rows over, keeping any
* credential the target database already has for the same integration.
*/
export function importNextCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], sourcePath: string) {
return Effect.gen(function* () {
if (!existsSync(sourcePath)) return
for (const row of yield* readSourceCredentials(sourcePath)) {
const integrationID = typeof row.integration_id === "string" && row.integration_id.length ? row.integration_id : undefined
if (!integrationID) continue
if (typeof row.value !== "string") continue
const json = Option.getOrUndefined(decodeJson(row.value))
if (json === undefined || Option.isNone(decodeValue(json))) continue
if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue
const now = Date.now()
yield* tx.run(sql`
INSERT OR IGNORE INTO credential (
id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
) VALUES (
${typeof row.id === "string" && row.id.length ? row.id : Credential.ID.create()},
${integrationID},
${typeof row.label === "string" && row.label.length ? row.label : "default"},
${row.value},
${typeof row.connector_id === "string" ? row.connector_id : null},
${typeof row.method_id === "string" ? row.method_id : null},
${typeof row.active === "number" ? row.active : null},
${typeof row.time_created === "number" ? row.time_created : now},
${typeof row.time_updated === "number" ? row.time_updated : now}
)
`)
}
})
}
type SourceRow = Record<string, unknown>
// An unreadable or incompatible source database skips the import instead of
// failing the migration and blocking startup; the source is never modified.
function readSourceCredentials(sourcePath: string) {
return Effect.scoped(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
const source = yield* Effect.acquireRelease(
Effect.try({
try: () => new sqlite.Database(sourcePath, { readonly: true, strict: true }),
catch: (error) => error,
}),
(database) => Effect.sync(() => database.close()),
)
return yield* Effect.try({
try: () => {
const table = source
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
.get()
if (!table) return [] as SourceRow[]
return source.query<SourceRow, []>("SELECT * FROM credential").all()
},
catch: (error) => error,
})
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("Skipped incompatible opencode-next.db credentials", { path: sourcePath, error }).pipe(
Effect.as([] as SourceRow[]),
),
),
)
}
-323
View File
@@ -1,323 +0,0 @@
export * as SessionTransfer from "./transfer"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Bus } from "../bus"
import { Database } from "../database/database"
import { Location } from "../location"
import { Project } from "../project"
import { ProjectTable } from "../project/sql"
import { AbsolutePath, RelativePath } from "../schema"
import { Session } from "../session"
import { Slug } from "../util/slug"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionProjector } from "./projector"
import { SessionMessageTable, SessionTable } from "./sql"
export const Data = SessionTransfer.Data
export type Data = SessionTransfer.Data
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
"SessionTransfer.ImportConflictError",
{ sessionID: Session.ID },
) {}
export interface Interface {
readonly export: (input: {
sessionID: Session.ID
sanitize?: boolean
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
readonly import: (input: {
data: Data
location: Location.Ref
}) => Effect.Effect<Session.Info, ImportConflictError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const app = yield* App.Metadata
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const projects = yield* Project.Service
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
return Service.of({
export: Effect.fn("SessionTransfer.export")(function* (input) {
const data = {
info: yield* sessions.get(input.sessionID),
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
}
return input.sanitize ? sanitize(data) : data
}),
import: Effect.fn("SessionTransfer.import")(function* (input) {
const sessionID = input.data.info.id
const recorded = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (recorded) return yield* new ImportConflictError({ sessionID })
const project = yield* projects.resolve(input.location.directory)
yield* persistProject(project)
const messages = input.data.messages.map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id: message.id,
session_id: sessionID,
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data,
}
})
yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
location: input.location,
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
title: input.data.info.title,
agent: input.data.info.agent,
model: input.data.info.model,
},
{
location: input.location,
commit: (seq) =>
Effect.gen(function* () {
if (messages.length > 0) {
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
}
yield* db
.update(SessionTable)
.set({
cost: input.data.info.cost,
tokens_input: input.data.info.tokens.input,
tokens_output: input.data.info.tokens.output,
tokens_reasoning: input.data.info.tokens.reasoning,
tokens_cache_read: input.data.info.tokens.cache.read,
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
},
)
.pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionProjector.SessionAlreadyProjected
? Effect.fail(new ImportConflictError({ sessionID }))
: Effect.die(defect),
),
)
return yield* sessions.get(sessionID).pipe(Effect.orDie)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
})
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
}
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
if (!value) return value
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
}
function sanitize(data: Data): Data {
return {
info: {
...data.info,
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
location: {
...data.info.location,
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
},
revert: data.info.revert
? {
...data.info.revert,
files: data.info.revert.files?.map((file, index) => ({
...file,
file: redact("revert-file", String(index), file.file),
patch: redact("revert-patch", String(index), file.patch),
})),
}
: undefined,
},
messages: data.messages.map(sanitizeMessage),
}
}
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
const meta = metadata("message-metadata", message.id, message.metadata)
if (message.type === "user")
return {
...message,
metadata: meta,
text: redact("text", message.id, message.text),
files: message.files?.map((file, index) => ({
...file,
data: "",
source: { type: "inline" },
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
description:
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
mention: file.mention
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
: undefined,
})),
agents: message.agents?.map((agent, index) => ({
...agent,
name: redact("agent-name", String(index), agent.name),
mention: agent.mention
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
: undefined,
})),
}
if (message.type === "synthetic")
return {
...message,
metadata: meta,
text: redact("synthetic", message.id, message.text),
description:
message.description === undefined
? undefined
: redact("synthetic-description", message.id, message.description),
}
if (message.type === "system")
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
if (message.type === "shell")
return {
...message,
metadata: meta,
command: redact("shell-command", message.id, message.command),
output: message.output
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
: undefined,
}
if (message.type === "assistant")
return {
...message,
metadata: meta,
content: message.content.map((content) => {
if (content.type === "text")
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
}
return {
...content,
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
providerResultState: content.providerResultState
? { redacted: `tool-provider-result-state:${message.id}` }
: undefined,
state: sanitizeToolState(message.id, content.state),
}
}),
}
if (message.type === "compaction") {
if (message.status === "failed")
return {
...message,
metadata: meta,
}
return {
...message,
metadata: meta,
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
}
}
return { ...message, metadata: meta }
}
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
if (state.status === "running")
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
if (state.status === "completed")
return {
...state,
input: { redacted: `tool-input:${id}` },
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
metadata: meta,
}
}
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
return {
...content,
uri: redact("tool-file-uri", id, content.uri),
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
}
}
+55 -77
View File
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { Effect, Schema } from "effect"
import { Form } from "../../form"
import { KV } from "../../kv"
import { Permission } from "../../permission"
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -30,7 +29,6 @@ export const Plugin = {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
.transform((draft) =>
@@ -51,90 +49,70 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return yield* Effect.void
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (
typeof providerID !== "string" ||
!providers.some((provider) => provider.id === providerID)
)
return yield* new WebSearch.ProviderRequiredError()
return yield* kv.set("websearch:provider", providerID)
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.andThen(Effect.suspend(search)),
)
}),
)
const result = yield* search()
: undefined
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", providerID)
return yield* ctx.websearch.query(input)
})
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
+92 -71
View File
@@ -1,8 +1,5 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -13,54 +10,82 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
),
const keys = new Set([
"logLevel",
"server",
"command",
"reference",
"snapshot",
"plugin",
"autoshare",
"disabled_providers",
"enabled_providers",
"small_model",
"mode",
"agent",
"provider",
"permission",
"tools",
"attachment",
"layout",
])
export function isV1(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const record = input as Record<string, unknown>
if (Object.keys(record).some((key) => keys.has(key))) return true
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
const mcp = record.mcp
return (
typeof mcp === "object" &&
mcp !== null &&
!Array.isArray(mcp) &&
!("servers" in mcp) &&
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
)
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
@@ -107,7 +132,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
export function normalizeAction(action: string) {
function normalizeAction(action: string) {
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
@@ -129,25 +154,21 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
}
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([id, command]) => [
@@ -184,7 +205,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
export function migrateMcp(info: ConfigMCPV1.Info) {
function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
return {
@@ -223,7 +244,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
)
}
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
return migrateStandardProvider(info)
@@ -235,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
headers: info.options && options.headers,
body: info.options && options.body,
models:
@@ -279,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
}
}
// Rename these only while migrating unambiguous V1 fields.
export function providerID(input: string) {
// Rename these only in files detected as V1 by a field that exists only in the old config format.
function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex"
return input
-91
View File
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { advance, drain } from "../lib/clock"
@@ -60,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
}),
)
it.effect("applies remote permission defaults before explicit global and build rules", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
const entries = [
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"*.dev.vars": "deny",
"~/.local/share/opencode/mcp-auth.json": "deny",
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
},
external_directory: {
"*": "ask",
"~/.local/share/opencode/*": "deny",
},
},
}),
),
}),
new Document({
type: "document",
info: decode({
permissions: [{ action: "*", resource: "*", effect: "allow" }],
agents: {
build: {
permissions: [
{ action: "external_directory", resource: "*", effect: "allow" },
{
action: "external_directory",
resource: "~/.local/share/opencode/*",
effect: "deny",
},
{ action: "read", resource: "*.env", effect: "deny" },
],
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer(entries)),
)
const build = yield* agents.get(Agent.defaultID)
if (!build) throw new Error("expected configured build agent")
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
expect(build.permissions).toEqual([
...defaultPermissions(global),
{ action: "question", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "deny" },
{ action: "read", resource: "*.env.*", effect: "deny" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "read", resource: "*.dev.vars", effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "allow" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "read", resource: "*.env", effect: "deny" },
])
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
}),
)
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+25 -106
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("loads authenticated wellknown config before user configuration", () =>
it.live("loads authenticated wellknown config at highest priority", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -370,13 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const initial = yield* config.entries()
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["secret", "global", "project"])
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -384,13 +378,7 @@ describe("Config", () => {
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
const refreshed = yield* config.entries()
expect(Config.latest(refreshed, "shell")).toBe("project")
expect(
refreshed.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["next", "global", "project"])
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
}).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
)
@@ -399,96 +387,27 @@ describe("Config", () => {
),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
const details = entry.message[1]
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
})
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const malformed = path.join(tmp.path, "malformed.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), "null")
await fs.writeFile(path.join(project, "opencode.json"), "")
await fs.writeFile(malformed, '{ "credential": "file-secret"')
})
const integrationID = Integration.ID.make("https://invalid.example.com")
const entry: WellKnown.Entry = {
origin: "https://invalid.example.com",
integrationID,
manifest: { auth: { command: ["login"], env: "TOKEN" } },
}
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.die("unused Credential.all"),
list: () =>
Effect.succeed([
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
}),
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
),
deps: [],
})
const wellknownNode = makeGlobalNode({
service: WellKnown.Service,
layer: Layer.succeed(
WellKnown.Service,
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
}),
),
deps: [],
})
it.effect("detects v1 configuration from any v1-only top-level key", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
}),
)
yield* Config.Service.use((config) => config.entries()).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
file: malformed,
content: "",
}),
),
)
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
[
`${path.join(global, "opencode.json")}:$:invalid`,
`${path.join(project, "opencode.json")}:$:invalid`,
`${malformed}:$:invalid`,
"https://invalid.example.com:$:invalid",
"OPENCODE_CONFIG_CONTENT:$:invalid",
].toSorted(),
)
expect(JSON.stringify(output)).not.toContain("secret")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
Effect.sync(() => {
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}),
)
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
@@ -597,12 +516,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
@@ -1,489 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Info } from "@opencode-ai/schema/config"
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
function normalized(input: unknown) {
const result = ConfigNormalize.normalize(input)
expect(result.type).toBe("normalized")
if (result.type !== "normalized") throw new Error("expected normalized config")
return result
}
function decoded(input: unknown) {
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
}
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
const result = structuredClone(input)
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
const mcp = result.mcp as Record<string, unknown>
const originallyEmpty = !Object.keys(mcp).length
for (const key of ["servers", "timeout"]) {
if (
typeof mcp[key] === "object" &&
mcp[key] !== null &&
!Array.isArray(mcp[key]) &&
!Object.keys(mcp[key]).length
)
delete mcp[key]
}
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
}
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
const compaction = result.compaction as Record<string, unknown>
const originallyEmpty = !Object.keys(compaction).length
if (
typeof compaction.keep === "object" &&
compaction.keep !== null &&
!Array.isArray(compaction.keep) &&
!Object.keys(compaction.keep).length
)
delete compaction.keep
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
}
return result
}
describe("ConfigNormalize", () => {
test("rejects every non-object root with one root diagnostic", () => {
for (const input of [null, [], "config", true, 1]) {
expect(ConfigNormalize.normalize(input)).toEqual({
type: "rejected",
diagnostics: [
{
kind: "invalid",
path: ["$"],
message: "rejected configuration because its root is not an object",
},
],
})
}
})
test("keeps unrelated native fields when a legacy field is present", () => {
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
expect(result.snapshots).toBe(false)
expect(result.agents?.reviewer?.system).toBe("Use V2")
})
test("canonicalizes transformed native values through decode then encode", () => {
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
const info = Schema.decodeUnknownSync(Info)(result.encoded)
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
})
test("preserves arbitrary JSON-round-tripped native configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(Info), (info) => {
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
const result = normalized(source)
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
)
}),
{ numRuns: 100 },
)
})
test("merges named maps by entry and gives valid native entries precedence", () => {
const result = normalized({
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
commands: { native: { template: "native" }, duplicate: { template: "new" } },
})
expect(result.encoded.references).toEqual({
legacy: { path: "../legacy" },
native: { path: "../native" },
duplicate: { path: "../new" },
})
expect(result.encoded.commands).toEqual({
legacy: { template: "legacy" },
native: { template: "native" },
duplicate: { template: "new" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["references", "duplicate"],
["commands", "duplicate"],
])
})
test("does not report canonical-equal duplicates as conflicts", () => {
const result = normalized({
snapshot: false,
snapshots: false,
reference: { docs: { path: "../docs" } },
references: { docs: { path: "../docs" } },
agent: { reviewer: { prompt: "same" } },
agents: { reviewer: { system: "same" } },
provider: { custom: { name: "same" } },
providers: { custom: { name: "same" } },
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
})
test("uses agent then mode then native agent precedence", () => {
const result = normalized({
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
})
expect(result.encoded.agents).toEqual({
reviewer: { system: "native" },
agentOnly: { system: "agent-only" },
modeOnly: { system: "mode-only", mode: "primary" },
nativeOnly: { system: "native-only" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["agents", "reviewer"],
["agents", "reviewer"],
])
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("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()
})
})
+2 -10
View File
@@ -237,11 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: {
chat: {
name: "First",
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { importNextCredentials } from "@opencode-ai/core/database/migration/20260806200000_import_next_credentials"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -164,6 +165,75 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("imports previous channel database credentials without replacing existing integrations", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "opencode-next.db")
const { Database: Sqlite } = await import("bun:sqlite")
const sourceDb = new Sqlite(source, { strict: true })
sourceDb.run(`
CREATE TABLE credential (
id text PRIMARY KEY, integration_id text, label text NOT NULL, value text NOT NULL,
connector_id text, method_id text, active integer, time_created integer NOT NULL, time_updated integer NOT NULL
)
`)
const insert = sourceDb.prepare(
"INSERT INTO credential (id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
insert.run("cred_next_opencode", "opencode", "anomaly", JSON.stringify({ type: "key", key: "zen-key" }), null, "console", null, 100, 200)
insert.run(
"cred_next_anthropic",
"anthropic",
"default",
JSON.stringify({ type: "oauth", methodID: "oauth", refresh: "next-refresh", access: "next-access", expires: 456 }),
null,
null,
null,
100,
200,
)
insert.run("cred_next_invalid", "invalid", "default", "not-json", null, null, null, 100, 200)
sourceDb.close()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const now = Date.now()
yield* db.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A second run is a no-op because the integration now has a credential.
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A missing source database is a no-op.
yield* db.transaction((tx) => importNextCredentials(tx, path.join(tmp.path, "missing.db")))
expect(
yield* db.all(sql`SELECT id, integration_id, label, value, method_id, time_created FROM credential ORDER BY integration_id`),
).toEqual([
{
id: "existing",
integration_id: "anthropic",
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
method_id: null,
time_created: now,
},
{
id: "cred_next_opencode",
integration_id: "opencode",
label: "anomaly",
value: JSON.stringify({ type: "key", key: "zen-key" }),
method_id: "console",
time_created: 100,
},
])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+2 -11
View File
@@ -194,11 +194,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
apiKey: "settings-secret",
baseURL: "https://compatible.example/v1",
@@ -208,8 +204,7 @@ describe("ModelResolver", () => {
body: {},
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
const prepared = yield* compileRequest(request)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
@@ -221,10 +216,6 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
expect(resolved.compatibility?.requireFinishReason).toBe(false)
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
expect(prepared.body).not.toHaveProperty("max_tokens")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({})
}),
+1 -83
View File
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
SessionTransfer.node,
]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, projects],
@@ -748,77 +740,3 @@ describe("Session.create", () => {
}),
)
})
describe("SessionTransfer", () => {
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported" })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
type: "user",
text: "Imported message",
time: { created: DateTime.makeUnsafe(100) },
},
{
id: errorMessageID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "test_error", message: "Original error" },
time: { created: DateTime.makeUnsafe(101) },
},
],
},
location,
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
"compaction",
"user",
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
}),
)
it.effect("rejects an existing session ID without changing its transcript", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const existing = yield* session.create({ location, title: "Existing" })
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
expect(exit._tag).toBe("Failure")
expect((yield* session.get(existing.id)).title).toBe("Existing")
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
}),
)
})
+2 -45
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
@@ -39,8 +39,6 @@ const providers = [
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -54,8 +52,6 @@ beforeEach(() => {
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
default: () =>
Effect.gen(function* () {
const stored = values.get("websearch:provider")
if (stored === false) return yield* new WebSearch.DisabledError()
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
}),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
const stored = values.get("websearch:provider")
if (queryBarrier && synchronizedQueries < 5) {
synchronizedQueries++
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
@@ -330,35 +316,6 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
queryBarrier = yield* Deferred.make<void>()
const registry = yield* Tool.Service
const results = yield* Effect.all(
Array.from({ length: 5 }, (_, index) =>
executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-concurrent-${index}`,
name: "websearch",
input: { query: `effect ${index}` },
},
}),
),
{ concurrency: "unbounded" },
)
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(values.get("websearch:provider")).toBe("exa")
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,5 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
@@ -164,36 +163,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.import", "/api/session/import", {
payload: Schema.Struct({
...SessionTransfer.Data.fields,
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: ConflictError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.import",
summary: "Import session",
description: "Import a projected session transcript at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
params: { sessionID: Session.ID },
query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionTransfer.Data }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.export",
summary: "Export session",
description: "Export a complete projected session transcript.",
}),
),
)
.add(
HttpApiEndpoint.get("session.active", "/api/session/active", {
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath, optional } from "./schema.js"
import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(optional).annotate({
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(optional).annotate({
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(optional).annotate({
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(optional),
url: Schema.String.pipe(Schema.optional),
})
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(optional).annotate({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(optional).annotate({
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(optional).annotate({
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(optional).annotate({
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(optional).annotate({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(optional).annotate({
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(optional).annotate({
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(optional).annotate({
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, optional).annotate({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(optional).annotate({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(optional),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+4 -5
View File
@@ -1,7 +1,6 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+2 -3
View File
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(optional),
image: Image.pipe(Schema.optional),
}) {}
+1 -2
View File
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(optional),
variant: VariantID.pipe(Schema.optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+1 -2
View File
@@ -1,11 +1,10 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+24 -25
View File
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(optional),
}).pipe(Schema.optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(optional),
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: Capabilities.pipe(optional),
capabilities: Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}
+5 -6
View File
@@ -1,19 +1,18 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}
+3 -4
View File
@@ -1,16 +1,15 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(optional).annotate({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(optional).annotate({
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(optional).annotate({
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+1 -2
View File
@@ -1,8 +1,7 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, optional),
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
-1
View File
@@ -25,7 +25,6 @@ export { Vcs } from "./vcs.js"
export { SessionPending } from "./session-pending.js"
export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
export { Snapshot } from "./snapshot.js"
export { Shell } from "./shell.js"
export { Skill } from "./skill.js"
+18 -18
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(optional).annotate({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(optional).annotate({
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(optional).annotate({
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(optional).annotate({
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
-7
View File
@@ -47,16 +47,9 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.String,
]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
-11
View File
@@ -1,11 +0,0 @@
export * as SessionTransfer from "./session-transfer.js"
import { Schema } from "effect"
import { Session } from "./session.js"
import { SessionMessage } from "./session-message.js"
export interface Data extends Schema.Schema.Type<typeof Data> {}
export const Data = Schema.Struct({
info: Session.Info,
messages: Schema.Array(SessionMessage.Info),
}).annotate({ identifier: "SessionTransfer.Data" })
+1 -47
View File
@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
-19
View File
@@ -30,22 +30,3 @@ describe("Model.ReasoningField", () => {
expect(decode(field)).toBe(field)
})
})
describe("Model.Compatibility", () => {
test("decodes model compatibility overrides", () => {
const decode = Schema.decodeUnknownSync(Model.Compatibility)
expect(decode({})).toEqual({})
expect(
decode({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
}),
).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
})
})
-52
View File
@@ -1,5 +1,4 @@
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
return handlers
.handle(
@@ -88,56 +86,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.import",
Effect.fn(function* (ctx) {
return {
data: yield* transfer
.import({
data: { info: ctx.payload.info, messages: ctx.payload.messages },
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(
Effect.catchTag(
"SessionTransfer.ImportConflictError",
(error) =>
new ConflictError({
message: `Session already exists: ${error.sessionID}`,
resource: error.sessionID,
}),
),
),
}
}),
)
.handle(
"session.export",
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
),
}
}),
)
.handle(
"session.active",
Effect.fn(function* () {
-2
View File
@@ -16,7 +16,6 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
@@ -52,7 +51,6 @@ const applicationServices = LayerNode.group([
Job.node,
Project.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PermissionSaved.node,
+4 -20
View File
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+2 -2
View File
@@ -88,7 +88,7 @@ export const settings: Setting[] = [
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
},
@@ -96,7 +96,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "cwd",
default: "global",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
@@ -123,7 +123,6 @@ function manageConnections(
onConnected?: OnIntegrationConnected,
) {
dialog.replace(() => {
const theme = useTheme("elevated")
const data = useData()
const client = useClient()
const toast = useToast()
@@ -143,9 +142,6 @@ function manageConnections(
...credentialConnections(integration).map((connection) => ({
title: `Disconnect ${connection.label}`,
value: connection.id,
category: "Connected accounts",
bg: theme.background.action.destructive.focused,
fg: theme.text.action.destructive.focused,
onSelect: () => {
void client.api.credential
.remove({ credentialID: connection.id, location: location(data) })
+15 -41
View File
@@ -19,20 +19,13 @@ function statusError(status: McpServer["status"]) {
return undefined
}
function Status(props: { status: McpServer["status"]; loading: boolean }) {
if (props.loading || props.status.status === "pending") {
return <>Connecting </>
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useTheme("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
if (props.status.status === "connected") {
return <span style={{ attributes: TextAttributes.BOLD }}>Connected </span>
}
if (props.status.status === "failed") {
return <>Failed !</>
}
if (props.status.status === "needs_auth") {
return <>Sign in required </>
}
return <>Disabled </>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -45,13 +38,6 @@ export function DialogMcp() {
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
const statusColor = (status: McpServer["status"]) => {
if (status.status === "connected") return theme.text.feedback.success.default
if (status.status === "failed") return theme.text.feedback.error.default
if (status.status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
}
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
@@ -67,29 +53,17 @@ export function DialogMcp() {
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => {
const pending = loadingMcp === server.name || server.status.status === "pending"
return {
value: server.name,
title: server.name,
footer: <Status status={server.status} loading={pending} />,
footerColor: pending ? theme.text.subdued : statusColor(server.status),
}
})
})
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
const toggleTitle = createMemo(() => {
const status = focusedServer()?.status.status
if (status === "connected") return "disconnect"
if (status === "failed") return "retry"
if (status === "needs_auth") return "sign in"
return "connect"
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
})
const focusedError = createMemo(() => {
const server = focusedServer()
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusError(server.status) : undefined
})
@@ -126,7 +100,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
actions={[
{
title: toggleTitle(),
title: "toggle",
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
+1 -11
View File
@@ -179,7 +179,7 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse" | "tabs"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
attention: {
enabled: boolean
notifications: boolean
@@ -191,11 +191,6 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number }
mouse: boolean
tabs: {
enabled: boolean
scope: "global" | "cwd"
vertical?: boolean
}
}
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
@@ -226,11 +221,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true,
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
},
}
}
+3 -3
View File
@@ -49,7 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent()
const config = useConfig().data
const paths = useTuiPaths()
const enabled = () => config.tabs.enabled
const enabled = () => config.tabs?.enabled ?? false
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),
+16 -7
View File
@@ -824,13 +824,22 @@ export function Session() {
if (options === null) return
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking)
: JSON.stringify(
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
null,
2,
) + EOL
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking)
: await (async () => {
const messages: unknown[] = []
let cursor: string | undefined
do {
const page = await client.api.message.list(
cursor
? { sessionID: sessionData.id, limit: 200, cursor }
: { sessionID: sessionData.id, limit: 200, order: "asc" },
)
messages.push(...page.data)
cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined
} while (cursor)
return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL
})()
if (options.action === "copy") {
await clipboard.write?.(content)
+3 -47
View File
@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
export type DialogExportOptionsProps = {
defaultThinking: boolean
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean; sanitize: boolean }) => void
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void
onCancel?: () => void
}
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export"
type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
@@ -22,7 +22,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
sanitize: false,
active: "markdown" as Active,
})
@@ -31,7 +30,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
action,
format: store.format,
thinking: store.thinking,
sanitize: store.sanitize,
})
const activate = () => {
@@ -40,7 +38,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return
}
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
if (store.active === "copy" || store.active === "export") confirm(store.active)
}
@@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const order: Active[] =
store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"]
: ["markdown", "json", "sanitize", "copy", "export"]
: ["markdown", "json", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
},
},
@@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
flexDirection="row"
gap={1}
backgroundColor={
store.active === "sanitize"
? theme.background.formfield.focused
: store.sanitize
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "sanitize")
setStore("sanitize", !store.sanitize)
}}
>
<text
fg={
store.active === "sanitize"
? theme.text.formfield.focused
: store.sanitize
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.sanitize ? "[x]" : "[ ]"}
</text>
<text
fg={
store.active === "sanitize"
? theme.text.formfield.focused
: store.sanitize
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Sanitize sensitive data
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={4}
@@ -229,7 +186,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
action: "copy" | "export"
format: ExportFormat
thinking: boolean
sanitize: boolean
} | null>((resolve) => {
dialog.replace(
() => (
+1 -2
View File
@@ -84,8 +84,7 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description?.()}
<textarea
height={1}
wrapMode="none"
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
+1 -14
View File
@@ -71,7 +71,6 @@ export interface DialogSelectOption<T = any> {
detailsColor?: RGBA
detailsWrap?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
@@ -728,7 +727,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
}
footerColor={option.footerColor}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
@@ -786,7 +784,6 @@ function Option(props: {
current?: boolean
muted?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
@@ -835,17 +832,7 @@ function Option(props: {
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text
fg={
props.active && !props.muted
? text()
: props.muted && (props.active || props.current)
? theme.text.subdued
: (props.footerColor ?? theme.text.subdued)
}
>
{props.footer}
</text>
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
</box>
</Show>
</>
-7
View File
@@ -3,7 +3,6 @@ import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
import { settings } from "../src/component/dialog-config"
test("validates mini replay settings", () => {
const decode = Schema.decodeUnknownSync(Info)
@@ -39,12 +38,6 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd" })
})
test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
})
test("provides config and its host interface", async () => {
@@ -60,8 +60,8 @@ async function renderSessionTabs(
await Bun.write(
file,
JSON.stringify({
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
cwd: {},
}),
)
}
@@ -153,15 +153,15 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("stores session tabs for the current working directory by default", async () => {
test("stores session tabs globally by default", async () => {
const setup = await renderSessionTabs("first")
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } },
global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: {},
})
} finally {
setup.destroy()
@@ -180,7 +180,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
await titled.data.session.sync("shared")
await wait(async () => {
if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title"
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
})
const observed = ["Generated title"]
const pending = new Set<Promise<void>>()
@@ -189,7 +189,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
const read = Bun.file(file)
.json()
.then((value) => {
const title = value.cwd[directory]?.tabs[0]?.title
const title = value.global.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title)
})
.catch(() => undefined)
@@ -81,6 +81,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
},
@@ -92,6 +93,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| Field | Default | V2 behavior |
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
@@ -133,6 +135,8 @@ behavior.
## Current limitations
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
not implemented in V2.
- Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
+17 -54
View File
@@ -16,17 +16,15 @@ V2 has three intentional breaking changes:
- The [server API and clients](#server-api-and-clients) have new contracts.
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
Supported V1 functionality outside those areas is intended to remain compatible with V1. Some fields accepted by the V1
schema never had a V2 equivalent and are intentionally ignored; these are listed under
[Accepted but unsupported fields](#accepted-but-unsupported-fields).
All other functionality is intended to remain compatible with V1.
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
beta compatibility bug rather than an expected migration requirement.
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
an expected migration requirement.
<Callout type="tip">
Run `/report` if supported V1 functionality does not work in V2. The report skill collects diagnostics and helps you
file a compatibility issue.
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
a compatibility issue.
</Callout>
<Callout type="warning">
@@ -62,9 +60,8 @@ V2 reads existing global and project configuration from the same locations as V1
<project>/.opencode/opencode.json(c)
```
V2 reads these same locations. It normalizes supported V1 and native V2 fields in memory without rewriting the source
file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt
V2.
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
### Ask OpenCode to migrate
@@ -79,13 +76,7 @@ Preserve its behavior and all unrelated settings.
```
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
change. Conversion does not need to happen all at once: supported V1 and native V2 fields may coexist at the top level.
When both forms set the same canonical value, a valid native V2 value takes precedence regardless of JSON key order.
Nested mixing is intentionally bounded. OpenCode recognizes mixed V1 and V2 members within `mcp`, `compaction`, and
`experimental`, but it does not recursively infer formats inside individual agents, providers, commands, or models. Keep
each of those nested entries entirely in one format. Supported V1 syntax remains quiet by itself; malformed values,
unsupported legacy fields, and conflicting V1/V2 values produce warnings while unrelated valid settings continue to load.
change. Do not mix V1 and V2 field names manually in one file.
### Sharing
@@ -259,8 +250,8 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
}
```
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
context is retained by token budget instead. See [Compaction](/compaction).
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
See [Compaction](/compaction).
### Skills
@@ -363,17 +354,6 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
V2 consolidated two legacy provider namespaces:
| V1 provider ID | Canonical V2 provider ID |
| --- | --- |
| `azure-cognitive-services` | `azure` |
| `google-vertex-anthropic` | `google-vertex` |
Migration of unambiguous V1 provider, agent, command, and provider-filter fields uses these canonical IDs. The shared
top-level `model` field keeps its exact provider ID because the same syntax is valid in native V2 config; update that field
to the canonical ID when migrating a legacy built-in provider.
### Models and variants
Models remain nested under their provider, but several model fields become more explicit:
@@ -410,39 +390,22 @@ Models remain nested under their provider, but several model fields become more
See [Models](/models) for the complete native model shape.
### Supported fields without direct native equivalents
### Fields without native equivalents
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
### Accepted but unsupported fields
The V1 schema also accepted fields that have no supported V2 behavior. V2 ignores these values and emits a warning so
they are not mistaken for active configuration:
These V1 fields do not have one-to-one native V2 config fields:
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
- Agent `name` inside V1 JSON configuration.
- An enabled-only V1 MCP entry without a `type`.
- V1 experimental fields `disable_paste_summary`, `batch_tool`, `openTelemetry`, `primary_tools`, and
`continue_loop_on_deny`.
- V1 provider fields `id`, `whitelist`, and `blacklist`.
- V1 provider-model fields `release_date`, `attachment`, `reasoning`, `temperature`, `experimental`, a non-`deprecated`
`status`, and boolean `interleaved`.
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
Ignoring these fields is intentional and is not a compatibility regression. If V2 does not preserve behavior identified
as supported elsewhere in this guide, run `/report`.
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
### Agent files
+1517 -690
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-163
View File
@@ -1,163 +0,0 @@
# Mixed V1/V2 Config Normalization Plan
Status: **Implemented and verified**
## Goal
Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.
## Decision
Normalize recognized fields independently into the encoded side of the V2 `Config.Info` schema, then perform one final complete-document V2 decode:
```text
JSON/JSONC encoded input
-> parse and retain source-property presence
-> validate each recognized field or collection entry
-> migrate supported V1 candidates to V2 encoded values
-> decode and re-encode native V2 candidates
-> merge with native V2 precedence
-> decode Config.Info once
-> log redacted diagnostics
```
There is no whole-document version classification and no independent whole-document V1 and V2 decode.
The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of `Config.Info`.
## Behavior
| Situation | Result |
| --- | --- |
| Supported V1-only field | Migrate it to its canonical V2 destination. |
| Native V2 field | Preserve it after schema decode and encode. |
| Disjoint V1 and V2 map entries | Preserve both. |
| Same canonical scalar, map entry, or nested leaf | Valid native V2 wins regardless of JSON key order. |
| Malformed native value with valid legacy fallback | Skip native value, log it, and retain legacy value. |
| Malformed collection entry | Skip only the explicitly supported recovery unit. |
| Unsupported accepted V1 setting | Omit it and log a redacted warning. |
| Unknown field | Continue ignoring it for forward compatibility. |
Valid supported V1 syntax does not warn merely because it is legacy.
## Field Precedence
| Destination | Lowest to highest precedence |
| --- | --- |
| `snapshots` | `snapshot` < `snapshots` |
| `share` | `autoshare` < `share` |
| `references[name]` | `reference[name]` < `references[name]` |
| `agents[name]` | `agent[name]` < `mode[name]` < `agents[name]` |
| `commands[name]` | `command[name]` < `commands[name]` |
| `providers[name]` | `provider[name]` < `providers[name]` |
| `permissions` | `tools` rules < `permission` rules < native `permissions` |
| `plugins` | migrated `plugin` items < native `plugins` items |
| `media` | `attachment` < `media` |
| `experimental.policies` | enabled-provider policies < disabled-provider policies < native policies |
| `mcp.servers[name]` | direct legacy server < native `servers[name]` |
| `mcp.timeout.*` | `experimental.mcp_timeout` < native timeout leaf |
| `compaction.keep.tokens` | `preserve_recent_tokens` < `keep.tokens` |
| `compaction.buffer` | `reserved` < `buffer` |
Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.
## Shared Shapes
### Skills
- A V2 array retains each valid string item.
- A V1 object combines valid `paths` followed by valid `urls`.
- Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.
### MCP
- Direct entries under `mcp` are V1 servers.
- Entries under `mcp.servers` are native V2 servers.
- Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
- A malformed native duplicate is skipped so a valid legacy server remains.
- Native global timeout leaves override only matching values migrated from `experimental.mcp_timeout`.
- Raw `type` and `enabled` discriminators preserve legacy servers that happen to be named `servers` or `timeout`.
### Compaction
- `preserve_recent_tokens` becomes `keep.tokens`.
- `reserved` becomes `buffer`.
- Native leaves win conflicts.
- `tail_turns` and `prune` remain unsupported and produce warnings.
### Experimental
- `subagent_depth` is shared.
- Legacy provider lists generate ordered canonical policies.
- Native policies follow generated policies.
- An explicit empty `enabled_providers` keeps deny-all behavior.
- A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.
## Recovery Units
Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.
Every decoder preserves `propertyOrder: "original"` because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.
## Provider IDs
Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.
The shared top-level `model` field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.
This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.
## Diagnostics
Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.
Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.
## Implementation
- Add a pure `ConfigNormalize.normalize` module under `packages/core/src/config/`.
- Reuse field migration primitives from `packages/core/src/v1/config/migrate.ts`.
- Replace `ConfigMigrateV1.isV1` in `packages/core/src/config.ts` with normalization and one final V2 decode.
- Log diagnostics uniformly for files, `OPENCODE_CONFIG_CONTENT`, and well-known virtual config.
- Add property and table-driven config normalization tests.
- Update migration and compaction documentation.
## Verification
The implementation must establish:
1. Valid native V2 config preserves decoded meaning after encoded normalization.
2. Supported V1 fields preserve existing behavior.
3. Adding a legacy field cannot change unrelated native field interpretation.
4. Native V2 wins canonical conflicts independent of key order.
5. One malformed entry does not remove valid siblings.
6. Mixed MCP, compaction, and experimental values normalize deterministically.
7. Diagnostics are precise and value-redacted.
8. False, zero, empty, and absent values retain distinct presence semantics.
Run from `packages/core`:
```sh
bun test test/config
bun typecheck
```
Run from `packages/www` after documentation changes:
```sh
bun typecheck
bun validate
bun run build
```
## Non-Goals
- Runtime provider alias resolution.
- Provider policy or catalog changes.
- Model resolver or Session changes.
- Plugin API changes.
- Server or Protocol changes.
- Generation lifecycle changes.
- Recursive V1/V2 inference inside one agent, provider, command, or model.
- Restoring removed V1 functionality.
- Rewriting user files on disk.