Compare commits

..

1 Commits

Author SHA1 Message Date
James Long ff736fbbc8 feat(tui): coalesce equivalent forms 2026-08-06 17:48:30 +00:00
105 changed files with 5972 additions and 7065 deletions
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility), toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility), maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {} }) {}
export namespace LanguageModelCompatibility { export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = LanguageModel.input(updated) const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) 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.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
failed: "mcp.status.failed", failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth", needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled", disabled: "mcp.status.disabled",
} as const } as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
} }
const error = () => { const error = () => {
const s = mcpStatus() const s = mcpStatus()
if (s?.status === "failed") return s.error if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
} }
const enabled = () => status() === "connected" const enabled = () => status() === "connected"
return ( return (
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected", "bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed", "bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled", "bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth", "bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}} }}
/> />
<span class="flex flex-col min-w-0 flex-1"> <span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => { test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
}) })
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => { describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => { test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
}) })
test("ignores states that do not need user attention", () => { test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise" import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) { export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth") return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
} }
export function hasNonBlockingServiceIssue(input: { export function hasNonBlockingServiceIssue(input: {
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate, needs_auth: input.authenticate,
disabled: input.connect, disabled: input.connect,
failed: input.connect, failed: input.connect,
needs_client_registration: input.connect,
}[input.status]() }[input.status]()
await input.refresh() await input.refresh()
} }
-26
View File
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "List all available models", description: "List all available models",
params: ServerParams, 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", { Spec.make("mini", {
description: "Start the minimal interactive interface", description: "Start the minimal interactive interface",
params: { params: {
@@ -1,140 +0,0 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}
@@ -1,60 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Session } from "@opencode-ai/schema/session"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.import,
Effect.fn("cli.import")(function* (input) {
const text = yield* Effect.tryPromise({
try: () =>
input.file.startsWith("http://") || input.file.startsWith("https://")
? fetch(input.file).then((response) => {
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
return response.text()
})
: Bun.file(input.file).text(),
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const location = yield* Effect.promise(() =>
client.location.get({
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/session/import", server.endpoint.url), {
method: "POST",
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
body: JSON.stringify({
...encoded,
location: { directory: location.directory, workspaceID: location.workspaceID },
}),
}),
)
if (response.status === 409) {
process.stderr.write(`Session already exists${EOL}`)
return
}
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
)(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}),
)
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
case "needs_auth": case "needs_auth":
return "⚠" return "⚠"
case "failed": case "failed":
case "needs_client_registration":
return "✗" return "✗"
default: default:
return "○" return "○"
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
switch (status.status) { switch (status.status) {
case "needs_auth": case "needs_auth":
return "needs authentication" return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed": case "failed":
return `failed: ${status.error}` return `failed: ${status.error}`
default: default:
-2
View File
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/plugin/list"), list: () => import("./commands/handlers/plugin/list"),
}, },
models: () => import("./commands/handlers/models"), models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"), mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"), run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"), pair: () => import("./commands/handlers/pair"),
+6 -9
View File
@@ -94,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => { prepare: async (next) => {
const selected = const selected =
next.model ?? next.model ??
(await client.model (options.variant
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) ? await client.model
.then((result) => result.data)) .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected const model = selected
? { ? {
providerID: selected.providerID, providerID: selected.providerID,
@@ -106,12 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined : undefined
if ((options.variant ?? explicit?.variant) && !model) if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
const agent = return { model, agent: next.agent }
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
}, },
}).catch((error) => { }).catch((error) => {
if (!(error instanceof RunTargetError)) throw error if (!(error instanceof RunTargetError)) throw error
+2 -5
View File
@@ -56,16 +56,13 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent, agent: input.agent ?? selected?.agent,
signal: input.signal, signal: input.signal,
}) })
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session = const session =
selected ?? selected ??
(await input.client.session (await input.client.session
.create( .create(
{ {
agent: prepared.agent!, agent: prepared.agent,
model: prepared.model!, model: prepared.model,
location: { directory: location.directory, workspaceID: location.workspaceID }, location: { directory: location.directory, workspaceID: location.workspaceID },
}, },
...requestOptions(input.signal), ...requestOptions(input.signal),
-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 })
}
})
+6 -12
View File
@@ -61,11 +61,7 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => { const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create") order.push("create")
expect(input).toMatchObject({ expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
return session("ses_fresh", "/server", "work_1") return session("ses_fresh", "/server", "work_1")
}) })
@@ -75,22 +71,20 @@ describe("session target resolver", () => {
prepare: async (input) => { prepare: async (input) => {
order.push("prepare") order.push("prepare")
expect(input.location.workspaceID).toBe("work_1") expect(input.location.workspaceID).toBe("work_1")
return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" } return { model: input.model, agent: "prepared" }
}, },
}) })
expect(create).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"]) expect(order).toEqual(["prepare", "create"])
}) })
test("requires an explicit agent and model for a fresh Session", async () => { test("uses the agent resolved by the server for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create") spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow( const target = await resolveSessionTarget({ client, prepare })
"Creating a session requires an agent and model", expect(target.agent).toBe("review")
)
expect(create).not.toHaveBeenCalled()
}) })
test("does not retry an ambiguous Session creation", async () => { test("does not retry an ambiguous Session creation", async () => {
+86 -100
View File
@@ -120,61 +120,49 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = { export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined readonly id?: Session.ID | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly agent: Agent.ID readonly agent?: Agent.ID | undefined
readonly model: Model.Ref readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
} }
export type Endpoint5_1Output = Session.Info export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E> export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = { export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
readonly info: Session.Info export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
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_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined } export type Endpoint5_3Input = { readonly sessionID: Session.ID }
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> } export type Endpoint5_3Output = Session.Info
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E> 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 Endpoint5_4Input = { readonly sessionID: Session.ID }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E> 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 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 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_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_7Output = Session.Info export type Endpoint5_7Output = void
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E> 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 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_9Input = {
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 = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined readonly workspaceID?: Workspace.ID | undefined
} }
export type Endpoint5_11Output = void export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E> 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 sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly text: string readonly text: string
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_12Output = SessionPending.User export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E> 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 sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly command: string readonly command: string
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_13Output = SessionPending.User export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E> 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 sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID readonly skill: Skill.ID
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_14Output = void export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E> 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 sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly text: string readonly text: string
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_15Output = SessionPending.Synthetic export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E> 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 sessionID: Session.ID
readonly id?: Event.ID | undefined readonly id?: Event.ID | undefined
readonly command: string 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 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_17Input = {
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 = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined readonly files?: boolean | undefined
} }
export type Endpoint5_19Output = Session.Revert export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E> 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_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E> export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID } export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID } export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info> export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.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 SessionInstructionsEntryListOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_25Input = { export type Endpoint5_23Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_25Output = void export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_25Input, input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_25Output, E> ) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_26Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_26Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string } export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E> 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 sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_28Output = export type Endpoint5_26Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
} }
) )
| EventLog.Synced | 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_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID } export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> 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_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E> readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E> readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E> readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E> readonly remove: SessionRemoveOperation<E>
+89 -113
View File
@@ -21,10 +21,10 @@ import type {
Endpoint5_0Output, Endpoint5_0Output,
Endpoint5_1Input, Endpoint5_1Input,
Endpoint5_1Output, Endpoint5_1Output,
Endpoint5_2Input,
Endpoint5_2Output, Endpoint5_2Output,
Endpoint5_3Input, Endpoint5_3Input,
Endpoint5_3Output, Endpoint5_3Output,
Endpoint5_4Input,
Endpoint5_4Output, Endpoint5_4Output,
Endpoint5_5Input, Endpoint5_5Input,
Endpoint5_5Output, Endpoint5_5Output,
@@ -76,10 +76,6 @@ import type {
Endpoint5_28Output, Endpoint5_28Output,
Endpoint5_29Input, Endpoint5_29Input,
Endpoint5_29Output, Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -305,15 +301,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) => const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()( preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({ raw["session.create"]({
payload: { payload: {
id: input["id"], id: input?.["id"],
title: input["title"], title: input?.["title"],
agent: input["agent"], agent: input?.["agent"],
model: input["model"], model: input?.["model"],
location: input["location"], location: input?.["location"],
}, },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Inp
), ),
) )
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) => const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
preserveEffect<Endpoint5_2Output>()( preserveEffect<Endpoint5_2Output>()(
raw["session.import"]({ raw["session.active"]({}).pipe(
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), 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) => const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
preserveEffect<Endpoint5_3Output>()( 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.mapError(mapClientError),
Effect.map((value) => value.data), 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>()( preserveEffect<Endpoint5_4Output>()(
raw["session.active"]({}).pipe( raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
) )
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect<Endpoint5_5Output>()( 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.mapError(mapClientError),
Effect.map((value) => value.data), 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) => const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()( 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) => const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()( 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.mapError(mapClientError),
Effect.map((value) => value.data),
), ),
) )
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()( 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), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()( 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"]({ raw["session.move"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_12Output>()( preserveEffect<Endpoint5_10Output>()(
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { 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) => const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_13Output>()( preserveEffect<Endpoint5_11Output>()(
raw["session.command"]({ raw["session.command"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { 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) => const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_14Output>()( preserveEffect<Endpoint5_12Output>()(
raw["session.skill"]({ raw["session.skill"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_15Output>()( preserveEffect<Endpoint5_13Output>()(
raw["session.synthetic"]({ raw["session.synthetic"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { 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) => const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_16Output>()( preserveEffect<Endpoint5_14Output>()(
raw["session.shell"]({ raw["session.shell"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] }, payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_17Output>()( preserveEffect<Endpoint5_15Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_18Output>()( preserveEffect<Endpoint5_16Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_19Output>()( preserveEffect<Endpoint5_17Output>()(
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, 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) => const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()( 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) => const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()( 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) => const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()( 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.mapError(mapClientError),
Effect.map((value) => value.data), 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) => const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()( 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"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_26Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_27Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_28Output>()( preserveStream<Endpoint5_26Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, 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) => const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_31Output>()( preserveEffect<Endpoint5_29Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), 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"]) => ({ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw), list: Endpoint5_0(raw),
create: Endpoint5_1(raw), create: Endpoint5_1(raw),
import: Endpoint5_2(raw), active: Endpoint5_2(raw),
export: Endpoint5_3(raw), get: Endpoint5_3(raw),
active: Endpoint5_4(raw), remove: Endpoint5_4(raw),
get: Endpoint5_5(raw), fork: Endpoint5_5(raw),
remove: Endpoint5_6(raw), switchAgent: Endpoint5_6(raw),
fork: Endpoint5_7(raw), switchModel: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw), rename: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw), move: Endpoint5_9(raw),
rename: Endpoint5_10(raw), prompt: Endpoint5_10(raw),
move: Endpoint5_11(raw), command: Endpoint5_11(raw),
prompt: Endpoint5_12(raw), skill: Endpoint5_12(raw),
command: Endpoint5_13(raw), synthetic: Endpoint5_13(raw),
skill: Endpoint5_14(raw), shell: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw), compact: Endpoint5_15(raw),
shell: Endpoint5_16(raw), wait: Endpoint5_16(raw),
compact: Endpoint5_17(raw), revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
wait: Endpoint5_18(raw), context: Endpoint5_20(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, pending: { list: Endpoint5_21(raw) },
context: Endpoint5_22(raw), instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
pending: { list: Endpoint5_23(raw) }, generate: Endpoint5_25(raw),
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } }, log: Endpoint5_26(raw),
generate: Endpoint5_27(raw), interrupt: Endpoint5_27(raw),
log: Endpoint5_28(raw), background: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw), message: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -15,10 +15,6 @@ import type {
SessionListOutput, SessionListOutput,
SessionCreateInput, SessionCreateInput,
SessionCreateOutput, SessionCreateOutput,
SessionImportInput,
SessionImportOutput,
SessionExportInput,
SessionExportOutput,
SessionActiveOutput, SessionActiveOutput,
SessionGetInput, SessionGetInput,
SessionGetOutput, SessionGetOutput,
@@ -464,17 +460,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
create: (input: SessionCreateInput, requestOptions?: RequestOptions) => create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>( request<{ readonly data: SessionCreateOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: {
id: input["id"], id: input?.["id"],
title: input["title"], title: input?.["title"],
agent: input["agent"], agent: input?.["agent"],
model: input["model"], model: input?.["model"],
location: input["location"], location: input?.["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).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) => active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionActiveOutput }>( request<{ readonly data: SessionActiveOutput }>(
{ {
File diff suppressed because it is too large Load Diff
-2
View File
@@ -181,8 +181,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 }) const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active() const active = yield* client.session.active()
const created = yield* client.session.create({ const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+2 -6
View File
@@ -454,11 +454,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ const created = await client.session.create({ location: { directory: "/tmp/project" } })
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -532,7 +528,7 @@ test("middleware errors remain declared client errors", async () => {
}) })
try { try {
await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } }) await client.session.create({})
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isUnauthorizedError(error)).toBe(true) expect(isUnauthorizedError(error)).toBe(true)
+17 -35
View File
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location" import { Location } from "./location"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable" 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" import { WellKnown } from "./wellknown"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined { 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 reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) 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 errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true }) const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) { if (errors.length) return
yield* Effect.logWarning("configuration normalization diagnostic", { return Option.getOrUndefined(
source, ConfigMigrateV1.isV1(input)
path: "$", ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
kind: "invalid", : decodeInfo(input),
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
) )
if (result.type === "rejected") return }
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) { const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath) const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath) const info = parseInfo(substituted)
if (!info) return if (!info) return
return new Document({ type: "document", path: filepath, info }) return new Document({ type: "document", path: filepath, info })
}) })
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
text: JSON.stringify(config), text: JSON.stringify(config),
env: variables, env: variables,
}).pipe( }).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)), Effect.map(parseInfo),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
), ),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
Effect.orDie, Effect.orDie,
) )
: [] : []
const content = options?.content !== undefined const content = options?.content
? yield* ConfigVariable.substitute({ ? yield* ConfigVariable.substitute({
type: "virtual", type: "virtual",
source: "OPENCODE_CONFIG_CONTENT", source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory, dir: location.directory,
text: options.content, text: options.content,
}).pipe( }).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")), Effect.map(parseInfo),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])), Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie, Effect.orDie,
) )
@@ -251,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [ return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude, ...claude,
...agents, ...agents,
...(supplementary[0] ?? []), ...(supplementary[0] ?? []),
...explicit, ...explicit,
...direct, ...direct,
...supplementary.slice(1).flat(), ...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content, ...content,
] ]
}) })
-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 })
}
+3 -6
View File
@@ -161,14 +161,11 @@ function isPathAction(action: string): action is PathAction {
} }
function expandHome(resource: string, home: string) { function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home if (resource === "~") return home
if (resource === "$HOME") return home if (resource === "$HOME") return home
const relative = resource.startsWith("~/") if (resource.startsWith("$HOME/")) return home + resource.slice(5)
? resource.slice(2) if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource return resource
} }
+1
View File
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
id, id,
sessionID: input.sessionID, sessionID: input.sessionID,
title: input.title, title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }), ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields, fields: input.fields,
} }
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input.id, id: input?.id,
title: input.title, title: input?.title,
agent: input.agent, agent: input?.agent,
model: input.model, model: input?.model,
location: location:
input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
+77 -17
View File
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool" import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration" import { Integration } from "@opencode-ai/schema/integration"
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
}), }),
) )
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = { const context2: Context = {
app: host.app, app: host.app,
options: host.options, options: host.options,
@@ -265,25 +322,28 @@ export function fromPromise(plugin: Plugin) {
), ),
}, },
session: { session: {
hook: (name, callback) => hook: sessionHook,
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create({ host.session.create(
id: input.id == null ? undefined : Session.ID.make(input.id), input === undefined
agent: Agent.ID.make(input.agent), ? undefined
model: model(input.model), : {
location: id: input.id == null ? undefined : Session.ID.make(input.id),
input.location == null agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
? undefined model: input.model == null ? undefined : model(input.model),
: Location.Ref.make({ location:
directory: AbsolutePath.make(input.location.directory), input.location == null
workspaceID: ? undefined
input.location.workspaceID === undefined : Location.Ref.make({
? undefined directory: AbsolutePath.make(input.location.directory),
: Workspace.ID.make(input.location.workspaceID), workspaceID:
}), input.location.workspaceID === undefined
}), ? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
), ),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) => prompt: (input) =>
+9 -9
View File
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
} }
draft.cost = [] draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients. // Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 } draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
}) })
} }
}) })
yield* ctx.session.hook("http.request", (evt) => yield* ctx.session.hook("http", (evt) =>
Effect.sync(() => { evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(evt.request.url) const url = new URL(request.url)
evt.request.headers.set("originator", "opencode") request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID) request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return if (url.origin !== "https://api.openai.com") return next(request)
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request) return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
}), }),
) )
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage" import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000 const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000 const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000 const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000 const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
+35 -21
View File
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route" import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool" import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect" import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
const options: StreamOptions = { const options: StreamOptions = {
http: (request, handler) => http: (request, handler) =>
Effect.gen(function* () { Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", { let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
model: resolved.ref, model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request), use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}) })
let sent = HttpClientRequest.fromWeb(before.request) const send = (input: Request) =>
if (before.request.body) Effect.gen(function* () {
sent = HttpClientRequest.bodyUint8Array( let sent = HttpClientRequest.fromWeb(input)
sent, if (input.body)
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), sent = HttpClientRequest.bodyUint8Array(
before.request.headers.get("content-type") ?? undefined, sent,
) new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
const response = yield* handler(sent) input.headers.get("content-type") ?? undefined,
const after = yield* hooks.trigger("session", "http.response", { )
sessionID: session.id, latest = sent
agent: agent.id, const response = yield* handler(sent)
model: resolved.ref, const body = [204, 205, 304].includes(response.status)
request: before.request, ? null
response: new Response( : yield* Stream.toReadableStreamEffect(response.stream)
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream), const output = new Response(body, { status: response.status, headers: response.headers })
{ status: response.status, headers: response.headers }, origins.set(output, sent)
), return output
}) })
return HttpClientResponse.fromWeb(sent, after.response) const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
} }
if (promptCacheSnapshots) { if (promptCacheSnapshots) {
-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),
}
}
+57 -77
View File
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect" import { Effect, Schema } from "effect"
import { Form } from "../../form" import { Form } from "../../form"
import { KV } from "../../kv" import { KV } from "../../kv"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
export const name = "websearch" export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query." 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. 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 permission = yield* Permission.Service
const forms = yield* Form.Service const forms = yield* Form.Service
const kv = yield* KV.Service const kv = yield* KV.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
@@ -51,90 +49,72 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, id: context.id },
}) })
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> => const result = yield* ctx.websearch.query(input).pipe(
ctx.websearch.query(input).pipe( Effect.catch((error) => {
Effect.catch((error) => { if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) return Effect.gen(function* () {
return providerSelectionLock const providers = (yield* ctx.websearch.providers()).data
.withPermit( const defaultProvider = providers[0]
Effect.gen(function* () { if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
if (yield* websearch.default()) return yield* Effect.void const response = yield* forms.ask({
const providers = (yield* ctx.websearch.providers()).data sessionID: context.sessionID,
const defaultProvider = providers[0] title: "Web Search",
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() coalesce: `${context.messageID}:websearch-consent`,
const response = yield* forms.ask({ 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, sessionID: context.sessionID,
title: "Web Search", title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
key: "choice", key: "provider",
description: "Allow OpenCode to search the web for up-to-date information?", description: "Choose a provider for web search.",
type: "string", type: "string",
required: true, required: true,
custom: false, custom: false,
options: [ options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
{
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") : undefined
return yield* Effect.fail(new Error("Web search cancelled")) if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") { const providerID = selection?.answer.provider ?? defaultProvider.id
yield* kv.set("websearch:provider", false) if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.DisabledError() return yield* new WebSearch.ProviderRequiredError()
} yield* kv.set("websearch:provider", providerID)
const selection = return yield* ctx.websearch.query(input)
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()
const output = { const output = {
provider: result.data.providerID, provider: result.data.providerID,
results: result.data.results, results: result.data.results,
+92 -71
View File
@@ -1,8 +1,5 @@
export * as ConfigMigrateV1 from "./migrate" 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 { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent" import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command" import { ConfigCommandV1 } from "./command"
@@ -13,54 +10,82 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider" import { Provider } from "../../provider"
import { Model } from "../../model" import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const keys = new Set([
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions) "logLevel",
const encodeInfo = Schema.encodeSync(Info) "server",
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions) "command",
const encodeAgent = Schema.encodeSync(ConfigAgent.Info) "reference",
export function migrate(info: typeof ConfigV1.Info.Type) { "snapshot",
return encodeInfo( "plugin",
decodeInfo( "autoshare",
JSON.stringify({ "disabled_providers",
$schema: info.$schema, "enabled_providers",
shell: info.shell, "small_model",
model: modelSelection(info.model), "mode",
default_agent: info.default_agent, "agent",
autoupdate: info.autoupdate, "provider",
share: info.share ?? (info.autoshare ? "auto" : undefined), "permission",
enterprise: info.enterprise, "tools",
username: info.username, "attachment",
permissions: permissions(info.permission, info.tools), "layout",
agents: agents(info), ])
snapshots: info.snapshot,
watcher: info.watcher, export function isV1(input: unknown) {
formatter: info.formatter, if (typeof input !== "object" || input === null || Array.isArray(input)) return false
lsp: info.lsp, const record = input as Record<string, unknown>
media: info.attachment, if (Object.keys(record).some((key) => keys.has(key))) return true
tool_output: info.tool_output, // `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
mcp: mcp(info), // `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
compaction: info.compaction && { // counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
auto: info.compaction.auto, const mcp = record.mcp
prune: info.compaction.prune, return (
keep: { typeof mcp === "object" &&
tokens: info.compaction.preserve_recent_tokens, mcp !== null &&
}, !Array.isArray(mcp) &&
buffer: info.compaction.reserved, !("servers" in mcp) &&
}, Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
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),
}),
),
) )
} }
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) { function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [ const policies = [
...(info.enabled_providers === undefined ...(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. // 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 === "write" || action === "patch") return "edit"
if (action === "task") return "subagent" if (action === "task") return "subagent"
if (action === "bash") return "shell" if (action === "bash") return "shell"
@@ -129,25 +154,21 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }), ...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }), ...(info.top_p === undefined ? {} : { top_p: info.top_p }),
} }
return encodeAgent( return {
decodeAgent( model: modelSelection(info.model, info.variant),
JSON.stringify({ request: Object.keys(body).length ? { body } : undefined,
model: modelSelection(info.model, info.variant), system: info.prompt,
request: Object.keys(body).length ? { body } : undefined, description: info.description,
system: info.prompt, mode: info.mode,
description: info.description, hidden: info.hidden,
mode: info.mode, color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
hidden: info.hidden, steps: info.steps,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa", disabled: info.disable,
steps: info.steps, permissions: permissions(info.permission),
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 if (!info) return undefined
return Object.fromEntries( return Object.fromEntries(
Object.entries(info).map(([id, command]) => [ 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 } 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 const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local") if (info.type === "local")
return { 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 === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info) if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
return migrateStandardProvider(info) return migrateStandardProvider(info)
@@ -235,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name, name: info.name,
env: info.env, env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined, 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, headers: info.options && options.headers,
body: info.options && options.body, body: info.options && options.body,
models: models:
@@ -279,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
} }
} }
// Rename these only while migrating unambiguous V1 fields. // Rename these only in files detected as V1 by a field that exists only in the old config format.
export function providerID(input: string) { function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure" if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex" if (input === "google-vertex-anthropic") return "google-vertex"
return input return input
-96
View File
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { advance, drain } from "../lib/clock" import { advance, drain } from "../lib/clock"
@@ -51,11 +50,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () => it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test") const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect( expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow") ).toBe("allow")
@@ -65,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
}), }),
) )
it.effect("applies remote permission defaults before explicit global and build rules", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
const entries = [
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"*.dev.vars": "deny",
"~/.local/share/opencode/mcp-auth.json": "deny",
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
},
external_directory: {
"*": "ask",
"~/.local/share/opencode/*": "deny",
},
},
}),
),
}),
new Document({
type: "document",
info: decode({
permissions: [{ action: "*", resource: "*", effect: "allow" }],
agents: {
build: {
permissions: [
{ action: "external_directory", resource: "*", effect: "allow" },
{
action: "external_directory",
resource: "~/.local/share/opencode/*",
effect: "deny",
},
{ action: "read", resource: "*.env", effect: "deny" },
],
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer(entries)),
)
const build = yield* agents.get(Agent.defaultID)
if (!build) throw new Error("expected configured build agent")
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
expect(build.permissions).toEqual([
...defaultPermissions(global),
{ action: "question", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "deny" },
{ action: "read", resource: "*.env.*", effect: "deny" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "read", resource: "*.dev.vars", effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "allow" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "read", resource: "*.env", effect: "deny" },
])
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
}),
)
it.effect("applies all global permissions before agent-specific permissions", () => it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
+25 -106
View File
@@ -1,7 +1,7 @@
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { describe, expect } from "bun:test" 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 { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/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.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => (tmp) =>
@@ -370,13 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const initial = yield* config.entries() expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["secret", "global", "project"])
const updated = yield* bus const updated = yield* bus
.subscribe(Event.Updated) .subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -384,13 +378,7 @@ describe("Config", () => {
key = "next" key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID }) yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1) expect(yield* Fiber.join(updated)).toHaveLength(1)
const refreshed = yield* config.entries() expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
expect(Config.latest(refreshed, "shell")).toBe("project")
expect(
refreshed.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["next", "global", "project"])
}).pipe( }).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)), 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", () => { it.effect("detects v1 configuration from any v1-only top-level key", () =>
const output: Array<Record<string, unknown>> = [] Effect.sync(() => {
const logger = Logger.map(Logger.formatStructured, (entry) => { expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
const details = entry.message[1] expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>) expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
}) expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
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: [],
})
yield* Config.Service.use((config) => config.entries()).pipe( it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
Effect.provide( Effect.sync(() => {
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, { // V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
file: malformed, expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
content: "", 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)
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual( }),
[ )
`${path.join(global, "opencode.json")}:$:invalid`,
`${path.join(project, "opencode.json")}:$:invalid`,
`${malformed}:$:invalid`,
"https://invalid.example.com:$:invalid",
"OPENCODE_CONFIG_CONTENT:$:invalid",
].toSorted(),
)
expect(JSON.stringify(output)).not.toContain("secret")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () => it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => { Effect.sync(() => {
@@ -597,12 +516,12 @@ describe("Config", () => {
}) })
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined() expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({ expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" }, settings: { project: "test-project", location: "us-central1" },
models: { models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") }, "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() 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: { models: {
chat: { chat: {
name: "First", name: "First",
compatibility: { compatibility: { reasoningField: "vendor_reasoning" },
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
capabilities: { tools: true, input: ["text"], output: ["text"] }, capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true, disabled: true,
limit: { context: 100, output: 50 }, limit: { context: 100, output: 50 },
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID) expect(model.id).toBe(modelID)
expect(model.modelID).toBe(Model.ID.make("api-chat")) expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last") expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({ expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false) expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.limit).toEqual({ context: 100, output: 75 })
+2
View File
@@ -15,6 +15,7 @@ const input = {
id: formID, id: formID,
sessionID: SessionSchema.ID.make("ses_test"), sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form", title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }], fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput } satisfies Form.CreateInput
@@ -32,6 +33,7 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe) yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped) const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created) const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id) yield* service.cancel(form.id)
+2 -14
View File
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
http: { body: { custom_extension: { enabled: true } } }, http: { body: { custom_extension: { enabled: true } } },
}, },
}) })
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.max_output_tokens).toBeUndefined()
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
}), }),
) )
@@ -194,11 +191,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel( const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), { model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { compatibility: { reasoningField: "vendor_reasoning" },
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
settings: { settings: {
apiKey: "settings-secret", apiKey: "settings-secret",
baseURL: "https://compatible.example/v1", baseURL: "https://compatible.example/v1",
@@ -208,8 +201,7 @@ describe("ModelResolver", () => {
body: {}, body: {},
}), }),
) )
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } }) const request = LLM.request({ model: resolved, prompt: "Hello" })
const prepared = yield* compileRequest(request)
const headers = yield* resolved.route.auth.apply({ const headers = yield* resolved.route.auth.apply({
request, request,
method: "POST", method: "POST",
@@ -221,10 +213,6 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret") expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat") expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") 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.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({}) expect(resolved.route.defaults.http?.body).toEqual({})
}), }),
+82 -25
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai" import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin" import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host" import { host as testHost } from "./host"
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
}), }),
) )
it.effect("adapts promise session HTTP request and response hooks", () => it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise( yield* PluginPromise.fromPromise(
define({ define({
id: "promise-session-http", id: "promise-session-http-interrupt",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.session.hook("http.request", (event) => { await ctx.session.hook("http", (event) => {
event.request = new Request("https://provider.test/changed", event.request) event.use((request, next) => next(request))
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
})
}) })
}, },
}), }),
).effect(host) ).effect(host)
const context = { const started = yield* Deferred.make<void>()
sessionID: Session.ID.make("ses_promise_session_http"), const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
} }
const request = yield* hooks.trigger("session", "http.request", { yield* hooks.trigger("session", "http", event)
...context, const request = middlewares.reduce<SessionHttpHandler>(
request: new Request("https://provider.test", { method: "POST", body: "payload" }), (next, item) => (input: Request) => item(input, next),
}) () =>
const response = yield* hooks.trigger("session", "http.response", { Deferred.succeed(started, undefined).pipe(
...context, Effect.andThen(Effect.never),
request: request.request, Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
response: new Response(request.request.headers.get("x-hook") ?? "missing"), ),
}) )
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(request.request.url).toBe("https://provider.test/changed") expect(yield* Deferred.isDone(interrupted)).toBeTrue()
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}), }),
) )
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
} }
const http = Effect.fn(function* (providerID: Provider.ID, url: string) { const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: new Request(url, { method: "POST", body: "{}" }), use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}) })
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) } const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
}) })
describe("OpenAIPlugin", () => { describe("OpenAIPlugin", () => {
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai") expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([]) expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true) expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe( expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false, false,
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
false, false,
) )
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({ expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000, context: 272_000,
input: 272_000, input: 272_000,
output: 64_000, output: 64_000,
}) })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))) const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true) expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}), }),
) )
+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 { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace" import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
) )
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
SessionTransfer.node,
]),
[ [
[Bus.node, Bus.configured({ persist: true })], [Bus.node, Bus.configured({ persist: true })],
[Project.node, projects], [Project.node, projects],
@@ -748,77 +740,3 @@ describe("Session.create", () => {
}), }),
) )
}) })
describe("SessionTransfer", () => {
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported" })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
type: "user",
text: "Imported message",
time: { created: DateTime.makeUnsafe(100) },
},
{
id: errorMessageID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "test_error", message: "Original error" },
time: { created: DateTime.makeUnsafe(101) },
},
],
},
location,
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
"compaction",
"user",
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
}),
)
it.effect("rejects an existing session ID without changing its transcript", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const existing = yield* session.create({ location, title: "Existing" })
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
expect(exit._tag).toBe("Failure")
expect((yield* session.get(existing.id)).title).toBe("Existing")
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
}),
)
})
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
describe("SessionModelRequest HTTP bridge", () => { describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = [] const bodies: Uint8Array[] = []
const methods: string[] = [] const methods: string[] = []
const headers: Array<string | undefined> = []
const response = [ const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}', 'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', 'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method) methods.push(request.method)
bodies.push(request.body.body.slice()) bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(response, { headers: { "content-type": "text/event-stream" } }), new Response(response, { headers: { "content-type": "text/event-stream" } }),
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
}), }),
), ),
) )
const httpIt = testEffect( const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))), testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
) )
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () => retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () { Effect.gen(function* () {
bodies.length = 0 bodies.length = 0
methods.length = 0 methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service const agents = yield* Agent.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
catalog: catalogHost(catalog), catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) }, session: { hook: (name, callback) => hooks.register("session", name, callback) },
}) })
yield* pluginHost.session.hook("http.request", (event) => yield* pluginHost.session.hook("http", (event) =>
Effect.sync(() => { event.use((request, next) =>
seen.push("request") Effect.gen(function* () {
event.request.headers.set("x-hook", "effect") yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
}), return yield* next(request)
) }),
yield* pluginHost.session.hook("http.response", (event) => ),
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
) )
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true }) yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service const { db } = yield* Database.Service
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
yield* session.resume(retrySessionID) yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST"]) expect(methods).toEqual(["POST", "POST"])
expect(headers).toEqual(["effect"]) expect(bodies).toHaveLength(2)
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(bodies[0]?.byteLength).toBeGreaterThan(0) expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect((yield* session.context(retrySessionID))[1]).toMatchObject({ expect(bodies[1]).toEqual(bodies[0])
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
}), }),
) )
}) })
+4 -45
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test" 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
@@ -39,8 +39,6 @@ const providers = [
let providerRequired = false let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" } let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = [] const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let result = new WebSearch.Response({ let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -54,8 +52,6 @@ beforeEach(() => {
providerRequired = false providerRequired = false
formResponse = { status: "cancelled" } formResponse = { status: "cancelled" }
formResponses.length = 0 formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
result = new WebSearch.Response({ result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
transform: () => Effect.die("unused"), transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"), reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers), providers: () => Effect.succeed(providers),
default: () => default: () => Effect.succeed(undefined),
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
}),
query: (input) => query: (input) =>
Effect.gen(function* () { Effect.gen(function* () {
queries.push(input) queries.push(input)
const stored = values.get("websearch:provider") 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 (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string") if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
@@ -255,6 +241,7 @@ describe("WebSearchTool registration", () => {
{ {
sessionID, sessionID,
title: "Web Search", title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
@@ -312,6 +299,7 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({ expect(formRequests[1]).toEqual({
sessionID, sessionID,
title: "Choose a web search provider", title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
@@ -330,35 +318,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", () => it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () { Effect.gen(function* () {
providerRequired = true providerRequired = true
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
TEffectHKT TEffectHKT
> >
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
export class SQLiteEffectSelectBuilder< export class SQLiteEffectSelectBuilder<
out TSelection extends SelectedFields | undefined, TSelection extends SelectedFields | undefined,
out TRunResult, TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TBuilderMode extends "db" | "qb" = "db", TBuilderMode extends "db" | "qb" = "db",
> { > {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder" static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
} }
} }
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
export abstract class SQLiteEffectSession< export abstract class SQLiteEffectSession<
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TRunResult = unknown, TRunResult = unknown,
out TRelations extends AnyRelations = EmptyRelations, TRelations extends AnyRelations = EmptyRelations,
> { > {
static readonly [entityKind]: string = "SQLiteEffectSession" static readonly [entityKind]: string = "SQLiteEffectSession"
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
} }
export abstract class SQLiteEffectTransaction< export abstract class SQLiteEffectTransaction<
out TEffectHKT extends QueryEffectHKTBase, TEffectHKT extends QueryEffectHKTBase,
out TRunResult, TRunResult,
out TRelations extends AnyRelations = EmptyRelations, TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> { > extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction" static override readonly [entityKind]: string = "SQLiteEffectTransaction"
+10 -12
View File
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent" import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect" import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js" import type { Hooks } from "./registration.js"
export interface SessionContext { export interface SessionContext {
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionHttpRequest { export interface SessionHttp {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
request: Request readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
} }
export interface SessionHttpResponse { export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
readonly sessionID: Session.ID
readonly agent: Agent.ID export type SessionHttpMiddleware = (
readonly model: Model.Ref request: Request,
readonly request: Request next: SessionHttpHandler,
response: Response ) => Effect.Effect<Response, Error>
}
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly "http.request": SessionHttpRequest readonly http: SessionHttp
readonly "http.response": SessionHttpResponse
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+9 -11
View File
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionHttpRequest { export interface SessionHttp {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
request: Request readonly use: (middleware: SessionHttpMiddleware) => void
} }
export interface SessionHttpResponse { export type SessionHttpHandler = (request: Request) => Promise<Response>
readonly sessionID: Session.ID
readonly agent: Agent.ID export type SessionHttpMiddleware = (
readonly model: Model.Ref request: Request,
readonly request: Request next: SessionHttpHandler,
response: Response ) => Promise<Response> | Response
}
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly "http.request": SessionHttpRequest readonly http: SessionHttp
readonly "http.response": SessionHttpResponse
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
File diff suppressed because it is too large Load Diff
+3 -34
View File
@@ -1,5 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" 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 { SessionPending } from "@opencode-ai/schema/session-pending"
import { PromptInput } from "@opencode-ai/schema/prompt-input" import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
@@ -151,8 +150,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({ payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional), id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional),
agent: Agent.ID, agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref, model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: Session.Info }), success: Schema.Struct({ data: Session.Info }),
@@ -160,37 +159,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.create", identifier: "v2.session.create",
summary: "Create session", summary: "Create session",
description: "Create a session with an explicit agent and model at the requested location.", description: "Create a session at the requested location.",
}),
),
)
.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.",
}), }),
), ),
) )
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect" import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js" import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js" import { Permission } from "./permission.js"
import { AbsolutePath, optional } from "./schema.js" import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js" import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js" import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js" import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js" import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({ 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", 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", 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", 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", description: "Default primary agent to use when no session agent is selected",
}), }),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional) .pipe(Schema.optional)
.annotate({ .annotate({
description: "Automatically update or notify when a new version is available", 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", description: "Control whether sessions may be shared manually, automatically, or not at all",
}), }),
enterprise: Schema.Struct({ enterprise: Schema.Struct({
url: Schema.String.pipe(optional), url: Schema.String.pipe(Schema.optional),
}) })
.pipe(optional) .pipe(Schema.optional)
.annotate({ .annotate({
description: "Enterprise sharing service configuration", 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", 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", 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", 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", 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", 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", 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", 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", description: "Media processing configuration",
}), }),
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({ tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds", description: "Tool output truncation thresholds",
}), }),
mcp: ConfigMCP.Info.pipe(optional).annotate({ mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration", description: "MCP server configuration",
}), }),
compaction: ConfigCompaction.Info.pipe(optional).annotate({ compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior", 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", 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", 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", 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", 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", 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", 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)", description: "Keep recently active sessions warm with transient model requests (default: false)",
}), }),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(optional), experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {} }) {}
export class Document extends Schema.Class<Document>("Config.Document")({ export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"), type: Schema.Literal("document"),
path: Schema.String.pipe(optional), path: Schema.String.pipe(Schema.optional),
info: Info, info: Info,
}) {} }) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect" import { Schema } from "effect"
import { Permission } from "../permission.js" import { Permission } from "../permission.js"
import { optional, PositiveInt } from "../schema.js" import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js" import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js" import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({ export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(optional), model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(optional), request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(optional), system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(optional), description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional), mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(optional), hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(optional), color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(optional), steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(optional), permissions: Permission.Ruleset.pipe(Schema.optional),
}) {} }) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigCommand from "./command.js" export * as ConfigCommand from "./command.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js" import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({ export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String, template: Schema.String,
description: Schema.String.pipe(optional), description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(optional), agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(optional), model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(optional), subtask: Schema.Boolean.pipe(Schema.optional),
}) {} }) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js" export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect" 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")({ 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")({ export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(optional), auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(optional), keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(optional), buffer: NonNegativeInt.pipe(Schema.optional),
}) {} }) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js" export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect" import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js" import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js" import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({ 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.", 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", description: "Ordered policies controlling access to configured resources",
}), }),
}) {} }) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigFormatter from "./formatter.js" export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({ export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, optional), command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, optional), extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {} }) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) 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" export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({ export const Disabled = Schema.Struct({
disabled: Schema.Literal(true), disabled: Schema.Literal(true),
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({ export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array), command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, optional), extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional), env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {} }) {}
export const Entry = Schema.Union([Disabled, Server]) 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 { Schema } from "effect"
import { Mcp } from "../mcp.js" import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig export type Timeout = Mcp.TimeoutConfig
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({ export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(optional), timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(optional), servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {} }) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js" export * as ConfigMedia from "./media.js"
import { Schema } from "effect" 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")({ export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(optional), auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(optional), max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(optional), max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(optional), max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {} }) {}
export class Info extends Schema.Class<Info>("Config.Media")({ 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 { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js" import { Model } from "../model.js"
import { Provider } from "../provider.js" import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/)) const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.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({ const Explicit = Schema.Struct({
providerID: ProviderID, providerID: ProviderID,
model: ModelID, model: ModelID,
variant: VariantID.pipe(optional), variant: VariantID.pipe(Schema.optional),
}) })
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/)) const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+1 -2
View File
@@ -1,11 +1,10 @@
export * as ConfigPlugin from "./plugin.js" export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({ export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String, 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]) 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 { Schema } from "effect"
import { Money } from "../money.js" import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js" import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json) const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = { export const Overlays = {
settings: JsonRecord.pipe(optional), settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(optional), body: JsonRecord.pipe(Schema.optional),
} }
export class Request extends Schema.Class<Request>("Config.Provider.Request")({ 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")({ class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(optional), read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(optional), write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {} }) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({ class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({ tier: Schema.Struct({
type: Schema.Literal("context"), type: Schema.Literal("context"),
size: Schema.Int, size: Schema.Int,
}).pipe(optional), }).pipe(Schema.optional),
input: Money.USDPerMillionTokens, input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens, output: Money.USDPerMillionTokens,
cache: Cache.pipe(optional), cache: Cache.pipe(Schema.optional),
}) {} }) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(optional), context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(optional), input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(optional), output: Schema.Int.pipe(Schema.optional),
}) {} }) {}
class Model extends Schema.Class<Model>("Config.Model")({ class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional), modelID: ID.pipe(Schema.optional),
family: Family.pipe(optional), family: Family.pipe(Schema.optional),
name: Schema.String.pipe(optional), name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(optional), compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(optional), package: Schema.String.pipe(Schema.optional),
...Overlays, ...Overlays,
capabilities: Capabilities.pipe(optional), capabilities: Capabilities.pipe(Schema.optional),
variants: Schema.Struct({ variants: Schema.Struct({
id: VariantID, id: VariantID,
...Overlays, ...Overlays,
}).pipe(Schema.Array, optional), }).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional), cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(optional), limit: Limit.pipe(Schema.optional),
}) {} }) {}
export class Info extends Schema.Class<Info>("Config.Provider")({ export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional), name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, optional), env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(optional), package: Schema.String.pipe(Schema.optional),
...Overlays, ...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" export * as ConfigReference from "./reference.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({ export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String, repository: Schema.String,
branch: Schema.String.pipe(optional), branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(optional), description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(optional), hidden: Schema.Boolean.pipe(Schema.optional),
}) {} }) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({ export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String, path: Schema.String,
description: Schema.String.pipe(optional), description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(optional), hidden: Schema.Boolean.pipe(Schema.optional),
}) {} }) {}
export const Entry = Schema.Union([Schema.String, Git, Local]) export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js" export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js" import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({ export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(optional), max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(optional), max_bytes: PositiveInt.pipe(Schema.optional),
}) {} }) {}
+3 -4
View File
@@ -1,16 +1,15 @@
export * as ConfigWarming from "./warming.js" export * as ConfigWarming from "./warming.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({ 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", 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")', 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")', 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" export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({ export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, optional), ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {} }) {}
+3
View File
@@ -124,6 +124,9 @@ const InfoBase = {
// on non-session owners anywhere else. // on non-session owners anywhere else.
sessionID: Schema.String, sessionID: Schema.String,
title: Schema.String, title: Schema.String,
coalesce: Schema.String.pipe(optional).annotate({
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
}),
metadata: Metadata.pipe(optional), metadata: Metadata.pipe(optional),
} }
-1
View File
@@ -25,7 +25,6 @@ export { Vcs } from "./vcs.js"
export { SessionPending } from "./session-pending.js" export { SessionPending } from "./session-pending.js"
export { SessionError } from "./session-error.js" export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js" export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
export { Snapshot } from "./snapshot.js" export { Snapshot } from "./snapshot.js"
export { Shell } from "./shell.js" export { Shell } from "./shell.js"
export { Skill } from "./skill.js" export { Skill } from "./skill.js"
+23 -19
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js" import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({ 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.", 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.", 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.", 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")({ export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"), type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array), 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.", description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}), }),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(optional).annotate({ codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.", 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")({ export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(optional), client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(optional), client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(optional), scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional), callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(optional), redirect_uri: Schema.String.pipe(Schema.optional),
}) {} }) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({ export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"), type: Schema.Literal("remote"),
url: Schema.String, url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional), oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(optional), disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(optional).annotate({ codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.", 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")) export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({ const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth", identifier: "Mcp.Status.NeedsAuth",
}) })
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe( export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Schema.toTaggedUnion("status"), Schema.toTaggedUnion("status"),
) )
-7
View File
@@ -47,16 +47,9 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.String, Schema.String,
]).annotate({ identifier: "Model.ReasoningField" }) ]).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 interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({ export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional), reasoningField: ReasoningField.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" }) }).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {} 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 { describe, expect, test } from "bun:test"
import { Schema } from "effect" import { Schema } from "effect"
import { Config } from "../src/config.js" 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" import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => { describe("Config.Entry", () => {
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries) expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document) expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path") expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual([ expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([ expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" }, { action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" }, { action: "shell", resource: "git status", effect: "allow" },
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => { test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry") 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) 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 { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
return handlers return handlers
.handle( .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( .handle(
"session.active", "session.active",
Effect.fn(function* () { 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 { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell" import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job" import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
@@ -52,7 +51,6 @@ const applicationServices = LayerNode.group([
Job.node, Job.node,
Project.node, Project.node,
Session.node, Session.node,
SessionTransfer.node,
PluginRuntime.providerNode, PluginRuntime.providerNode,
SdkPlugins.node, SdkPlugins.node,
PermissionSaved.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 global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project") const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json") const config = path.join(project, "opencode.json")
yield* Effect.promise(() => yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() => yield* Effect.promise(() =>
fs.writeFile( fs.writeFile(
config, config,
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" }, { action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" }, { 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(() => const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }), fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
) )
const body: unknown = yield* Effect.promise(() => response.json()) const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body) yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true) 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" }, { action: "shell", resource: "git status", effect: "allow" },
]) ])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true) 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]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
), ),
) )
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+1 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () => const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
+7 -6
View File
@@ -88,7 +88,7 @@ export const settings: Setting[] = [
title: "Enabled", title: "Enabled",
category: "Tabs", category: "Tabs",
path: ["tabs", "enabled"], path: ["tabs", "enabled"],
default: true, default: false,
values: [false, true], values: [false, true],
labels: ["off", "on"], labels: ["off", "on"],
}, },
@@ -96,16 +96,17 @@ export const settings: Setting[] = [
title: "Scope", title: "Scope",
category: "Tabs", category: "Tabs",
path: ["tabs", "scope"], path: ["tabs", "scope"],
default: "cwd", default: "global",
values: ["cwd", "global"], values: ["cwd", "global"],
labels: ["current directory", "global"], labels: ["current directory", "global"],
}, },
{ {
title: "Layout", title: "Vertical",
category: "Tabs", category: "Tabs",
path: ["tabs", "layout"], path: ["tabs", "vertical"],
default: "horizontal", default: false,
values: ["horizontal", "vertical"], values: [false, true],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"], keywords: ["sidebar", "orientation", "left"],
}, },
{ {
@@ -6,7 +6,6 @@ import type {
IntegrationOauthConnectOutput, IntegrationOauthConnectOutput,
IntegrationOAuthMethod, IntegrationOAuthMethod,
} from "@opencode-ai/client" } from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data" import { useData } from "../context/data"
@@ -446,19 +445,6 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
mode: "modal", mode: "modal",
commands: [ commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{ {
bind: "c", bind: "c",
title: "Copy authorization details", title: "Copy authorization details",
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions} instructions={props.attempt.instructions}
message="Waiting for authorization..." message="Waiting for authorization..."
copy copy
open
/> />
) )
} }
@@ -574,14 +559,7 @@ function OAuthCode(props: {
) )
} }
function OAuthView(props: { function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme("elevated") const theme = useTheme("elevated")
return ( return (
@@ -605,18 +583,11 @@ function OAuthView(props: {
)} )}
</Show> </Show>
<text fg={theme.text.subdued}>{props.message}</text> <text fg={theme.text.subdued}>{props.message}</text>
<box flexDirection="row" gap={2}> <Show when={props.copy}>
<Show when={props.open}> <text fg={theme.text.default}>
<text fg={theme.text.default}> c <span style={{ fg: theme.text.subdued }}>copy</span>
o <span style={{ fg: theme.text.subdued }}>open</span> </text>
</text> </Show>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
</box> </box>
) )
} }
+16 -42
View File
@@ -15,24 +15,17 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) { function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined return undefined
} }
function Status(props: { status: McpServer["status"]; loading: boolean }) { function Status(props: { enabled: boolean; loading: boolean }) {
if (props.loading || props.status.status === "pending") { const theme = useTheme("elevated")
return <>Connecting </> 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={{ fg: theme.text.subdued }}> Disabled</span>
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 </>
} }
export function DialogMcp() { export function DialogMcp() {
@@ -45,13 +38,6 @@ export function DialogMcp() {
const [detail, setDetail] = createSignal<McpServer>() const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null) 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(() => const servers = createMemo(() =>
pipe( pipe(
data.location.mcp.server.list() ?? [], data.location.mcp.server.list() ?? [],
@@ -67,29 +53,17 @@ export function DialogMcp() {
const options = createMemo(() => { const options = createMemo(() => {
const loadingMcp = loading() const loadingMcp = loading()
return servers().map((server) => { return servers().map((server) => ({
const pending = loadingMcp === server.name || server.status.status === "pending" value: server.name,
return { title: server.name,
value: server.name, description: server.status.status,
title: server.name, footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === 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"
}) })
const focusedError = createMemo(() => { const focusedError = createMemo(() => {
const server = focusedServer() const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusError(server.status) : undefined return server ? statusError(server.status) : undefined
}) })
@@ -126,7 +100,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)} onSelect={(option) => open(option.value as string)}
actions={[ actions={[
{ {
title: toggleTitle(), title: "toggle",
command: "dialog.mcp.toggle", command: "dialog.mcp.toggle",
onTrigger: (option) => { onTrigger: (option) => {
setFocused(option.value as string) setFocused(option.value as string)
+2 -6
View File
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useData } from "../context/data" import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference" import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey)) const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected() const connected = useConnected()
const providers = createMemo( const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])), const models = createMemo(() => data.location.model.list() ?? [])
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID) const showExtra = createMemo(() => connected() && !props.providerID)
@@ -14,6 +14,7 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued return theme.text.subdued
} }
return ( return (
@@ -45,6 +46,9 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match> <Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match> <Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match> <Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch> </Switch>
</span> </span>
</text> </text>
+51 -50
View File
@@ -327,6 +327,10 @@ export function Prompt(props: PromptProps) {
if (!session) return if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id) if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID syncedSessionID = sessionID
}) })
@@ -939,43 +943,15 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selection = local.model.selection() const selectedModel = local.model.current()
if (!selection) { if (!selectedModel) {
void promptModelWarning() void promptModelWarning()
return false return false
} }
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = selection.variant const variant = local.model.variant.current()
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
@@ -993,8 +969,8 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location, location: directory ? { directory } : location,
agent: agent.id, agent: agent.id,
model: { model: {
providerID: selection.providerID, providerID: selectedModel.providerID,
id: selection.modelID, id: selectedModel.modelID,
variant, variant,
}, },
}) })
@@ -1014,6 +990,17 @@ export function Prompt(props: PromptProps) {
session = created session = created
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext() const editorSelection = editorContext()
@@ -1026,30 +1013,43 @@ export function Prompt(props: PromptProps) {
command: inputText, command: inputText,
}) })
setStore("mode", "normal") setStore("mode", "normal")
} else if (slashHead && isCommand) { } else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
const model = { providerID: selection.providerID, id: selection.modelID, variant } // Parse command from first line, preserve multi-line content in arguments
const cancelCommit = local.model.trackSessionCommit(sessionID, model) const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void client.api.session void client.api.session
.command({ .command({
sessionID, sessionID,
command: slashHead.name, command: command.slice(1),
arguments: slashHead.arguments, arguments: args,
agent: agent.id, agent: agent.id,
model, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
}) })
.catch((error) => { .catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
}) })
} else if (isSkill) { } else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: slashHead!.name, skill: inputText.split("\n")[0].split(" ")[0].slice(1),
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1061,15 +1061,13 @@ export function Prompt(props: PromptProps) {
await client.api.session.switchAgent({ sessionID, agent: agent.id }) await client.api.session.switchAgent({ sessionID, agent: agent.id })
} }
if ( if (
session?.model?.providerID !== selection.providerID || session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selection.modelID || session.model.id !== selectedModel.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default") (session.model.variant ?? "default") !== (variant ?? "default")
) { ) {
const model = { providerID: selection.providerID, id: selection.modelID, variant } await client.api.session.switchModel({
const cancelCommit = local.model.trackSessionCommit(sessionID, model) sessionID,
await client.api.session.switchModel({ sessionID, model }).catch((error) => { model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
cancelCommit()
throw error
}) })
} }
if (session?.revert) { if (session?.revert) {
@@ -1322,7 +1320,10 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"` return `Ask anything... "${list()[store.placeholder % list().length]}"`
})() })()
if (!value) return undefined if (!value) return undefined
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5 const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd() return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
}) })
const locationLabel = createMemo(() => { const locationLabel = createMemo(() => {
+3 -14
View File
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory", description: "Share tabs globally or keep a separate set for each working directory",
}), }),
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({ vertical: Schema.optional(Schema.Boolean).annotate({
description: "Show tabs in a horizontal strip or vertical sidebar", description: "Show tabs in a left sidebar instead of a horizontal strip",
}), }),
}), }),
).annotate({ description: "Tab strip settings" }), ).annotate({ description: "Tab strip settings" }),
@@ -179,7 +179,7 @@ export const Info = Schema.Struct({
}) })
export type Info = Schema.Schema.Type<typeof Info> 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: { attention: {
enabled: boolean enabled: boolean
notifications: boolean notifications: boolean
@@ -191,11 +191,6 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
keybinds: TuiKeybind.BindingLookupView keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number } leader: { timeout: number }
mouse: boolean mouse: boolean
tabs: {
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
}
} }
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved { export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
@@ -226,12 +221,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
}), }),
leader: { timeout: input.leader?.timeout ?? 2000 }, leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true, mouse: input.mouse ?? true,
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
},
} }
} }
+81 -185
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array" import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, createMemo, onCleanup } from "solid-js" import { batch, createMemo } from "solid-js"
import { useEvent } from "./event" import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
@@ -22,7 +22,6 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route" import { useRoute } from "./route"
import { useData } from "./data" import { useData } from "./data"
import { usePermission } from "./permission" import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) { export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/") const [providerID, ...rest] = model.split("/")
@@ -58,29 +57,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
const event = useEvent() const event = useEvent()
const permission = usePermission() const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) { function isModelValid(model: ModelPreferenceModel) {
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) { for (const modelFn of modelFns) {
const model = modelFn() const model = modelFn()
if (model && isModelValid(model)) return model if (!model) continue
if (isModelValid(model)) return model
} }
} }
function createAgent() { function createAgent() {
const agents = createMemo(() => const agents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), (data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
) )
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
@@ -132,40 +128,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent() const agent = createAgent()
function createModel() { function createModel() {
type ModelSelection = ModelPreferenceModel & { variant?: string } const [modelStore, setModelStore] = createStore<
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({ ModelPreference & {
ready: boolean
model: Record<string, ModelPreferenceModel>
}
>({
ready: false, ready: false,
model: {},
recent: [], recent: [],
favorite: [], favorite: [],
variant: {}, variant: {},
}) })
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingSelectionCommits = new Map<string, string>() const state = {
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false, pending: false,
} }
function savePreferences() { function save() {
if (!preferences.ready) { if (!modelStore.ready) {
saveState.pending = true state.pending = true
return return
} }
saveState.pending = false state.pending = false
void repository void repository
.patch({ .patch({
recent: preferences.recent, recent: modelStore.recent,
favorite: preferences.favorite, favorite: modelStore.favorite,
variant: preferences.variant, variant: modelStore.variant,
}) })
.catch(() => undefined) .catch(() => undefined)
} }
@@ -173,14 +164,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
repository repository
.load() .load()
.then((value) => { .then((value) => {
setPreferences("recent", value.recent) setModelStore("recent", value.recent)
setPreferences("favorite", value.favorite) setModelStore("favorite", value.favorite)
setPreferences("variant", value.variant) setModelStore("variant", value.variant)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
setPreferences("ready", true) setModelStore("ready", true)
if (saveState.pending) savePreferences() if (state.pending) save()
}) })
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
@@ -194,13 +185,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
for (const item of preferences.recent) { for (const item of modelStore.recent) {
if (isModelValid(item)) { if (isModelValid(item)) {
return item return item
} }
} }
const model = models()?.[0] const model = data.location.model.list()?.[0]
if (!model) return undefined if (!model) return undefined
return { return {
providerID: model.providerID, providerID: model.providerID,
@@ -208,134 +199,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
}) })
const newSessionModel = createMemo(() => {
const a = agent.current()
return getFirstValidModel(
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
)
})
const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => { const currentModel = createMemo(() => {
const selection = currentSelection() const a = agent.current()
if (!selection) return return (
return { providerID: selection.providerID, modelID: selection.modelID } getFirstValidModel(
}) () => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
function locationAgentKey(agentID: string) { fallbackModel,
const ref = location.ref ?? data.location.default() ) ?? undefined
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
) )
} })
function selectModel(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
return { return {
current: currentModel, current: currentModel,
selection: currentSelection,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
trackSessionCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingSelectionCommits.set(sessionID, committed)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
},
get ready() { get ready() {
return preferences.ready return modelStore.ready
},
get catalogReady() {
return models() !== undefined
}, },
recent() { recent() {
return preferences.recent return modelStore.recent
}, },
favorite() { favorite() {
return preferences.favorite return modelStore.favorite
}, },
parsed: createMemo(() => { parsed: createMemo(() => {
const value = currentSelection() const value = currentModel()
if (!value) { if (!value) {
return { return {
provider: "Connect a provider", provider: "Connect a provider",
@@ -343,28 +230,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false, reasoning: false,
} }
} }
const provider = providers()?.find((item) => item.id === value.providerID) const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID) const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? `${value.modelID} (unavailable)`, model: info?.name ?? value.modelID,
reasoning: (info?.variants?.length ?? 0) !== 0, reasoning: (info?.variants?.length ?? 0) !== 0,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
const current = currentSelection() const current = currentModel()
if (!current) return if (!current) return
const recent = recentModels(current, preferences.recent).filter(isModelValid) const recent = modelStore.recent
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1 if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0 if (next >= recent.length) next = 0
const val = recent[next] const val = recent[next]
if (!val) return if (!val) return
selectModel({ ...val }) const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = preferences.favorite.filter((item) => isModelValid(item)) const favorites = modelStore.favorite.filter((item) => isModelValid(item))
if (!favorites.length) { if (!favorites.length) {
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -373,7 +265,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
const current = currentSelection() const current = currentModel()
let index = -1 let index = -1
if (current) { if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
@@ -387,39 +279,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const next = favorites[index] const next = favorites[index]
if (!next) return if (!next) return
if (!selectModel({ ...next })) return const a = agent.current()
setPreferences("recent", recentModels(next, preferences.recent)) if (!a) return
savePreferences() setModelStore("model", a.id, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent))
save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
if (!selectModel(model)) return const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) { if (options?.recent) {
setPreferences("recent", recentModels(model, preferences.recent)) setModelStore("recent", recentModels(model, modelStore.recent))
savePreferences() save()
} }
}) })
}, },
toggleFavorite(model: { providerID: string; modelID: string }) { toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const exists = preferences.favorite.some( const exists = modelStore.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID, (x) => x.providerID === model.providerID && x.modelID === model.modelID,
) )
const next = exists const next = exists
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...preferences.favorite] : [model, ...modelStore.favorite]
setPreferences( setModelStore(
"favorite", "favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
) )
savePreferences() save()
}) })
}, },
variant: { variant: {
selected() { selected() {
return currentSelection()?.variant const m = currentModel()
if (!m) return undefined
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
}, },
current() { current() {
const v = this.selected() const v = this.selected()
@@ -427,20 +325,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return undefined return undefined
}, },
list() { list() {
const m = currentSelection() const m = currentModel()
if (!m) return [] if (!m) return []
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID) const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? [] return info?.variants?.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentSelection() const m = currentModel()
if (!m) return if (!m) return
if (route.data.type === "session") { setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) }) save()
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()
+3 -3
View File
@@ -49,7 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent() const event = useEvent()
const config = useConfig().data const config = useConfig().data
const paths = useTuiPaths() 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 // 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. // mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", { const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = [] let closedTabs: ClosedSessionTab[] = []
function state() { 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 return store.global
} }
function update(mutation: (draft: TabsState) => void) { 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( 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. // Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error), (error) => console.error("Failed to persist session tabs", error),
@@ -8,7 +8,13 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? []) const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length) const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo( const bad = createMemo(
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length, () =>
list().filter(
(item) =>
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
) )
const dot = (status: string) => { const dot = (status: string) => {
@@ -16,6 +22,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
if (status === "failed") return theme.text.feedback.error.default if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued return theme.text.subdued
} }
@@ -58,6 +65,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Match> </Match>
<Match when={item.status.status === "disabled"}>Disabled</Match> <Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match> <Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch> </Switch>
</span> </span>
</text> </text>
+2 -2
View File
@@ -93,13 +93,13 @@ export function Home() {
<box width="100%" flexShrink={0}> <box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" /> <PluginSlot name="home.footer" input={{}} mode="replace" />
</box> </box>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? ( return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}> <box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%"> <box width="100%">
<FormPrompt form={form} /> <FormPrompt form={form} forms={forms()} />
</box> </box>
</box> </box>
) : null ) : null
+39 -41
View File
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
} }
} }
export function FormPrompt(props: { form: FormWithLocation }) { export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
const client = useClient() const client = useClient()
const themes = useThemes() const themes = useThemes()
const theme = useTheme("elevated") const theme = useTheme("elevated")
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
let textarea: TextareaRenderable | undefined let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined let review: ScrollBoxRenderable | undefined
const forms = createMemo(() => {
if (!props.form.coalesce) return [props.form]
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
})
const message = createMemo(() => { const message = createMemo(() => {
const value = props.form.metadata?.["message"] const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined return typeof value === "string" ? value : undefined
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", "") setStore("error", "")
} }
function replySingle(field: FormAnswerField, value: FormValue) { function reply(answer: Record<string, FormValue>) {
client.api.form Promise.all(
.reply( forms().map((form) =>
{ client.api.form.reply(
sessionID: props.form.sessionID, {
formID: props.form.id, sessionID: form.sessionID,
answer: { [field.key]: value }, formID: form.id,
}, answer,
requestOptions(props.form), },
requestOptions(form),
),
),
).catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
) )
.catch((error: unknown) => { })
setStore( }
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" function replySingle(field: FormAnswerField, value: FormValue) {
? error.message reply({ [field.key]: value })
: "Invalid answer",
)
})
} }
function pick(value: FormValue, customValue?: string) { function pick(value: FormValue, customValue?: string) {
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
} }
function cancel() { function cancel() {
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) for (const form of forms())
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
} }
function openExternal() { function openExternal() {
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return return
} }
client.api.form reply(
.reply( Object.fromEntries(
{ fields().flatMap((field) => {
sessionID: props.form.sessionID, const value = store.answers[field.key]
formID: props.form.id, return value === undefined ? [] : [[field.key, value] as const]
answer: Object.fromEntries( }),
fields().flatMap((field) => { ),
const value = store.answers[field.key] )
return value === undefined ? [] : [[field.key, value] as const]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
} }
onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
+20 -11
View File
@@ -204,7 +204,7 @@ export function Session() {
const availableWidth = createMemo( const availableWidth = createMemo(
() => () =>
dimensions().width - dimensions().width -
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) (config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH ? SESSION_SIDEBAR_WIDTH
: 0), : 0),
) )
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => { createEffect(() => {
const current = prompt() const current = prompt()
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return if (sent || !current || !synced() || !local.model.ready) return
if (!local.agent.current() || !local.model.current()) return if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true sent = true
@@ -824,13 +824,22 @@ export function Session() {
if (options === null) return if (options === null) return
const content = const content =
options.format === "markdown" options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking) ? formatSessionTranscript(sessionData, messages(), options.thinking)
: JSON.stringify( : await (async () => {
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }), const messages: unknown[] = []
null, let cursor: string | undefined
2, do {
) + EOL 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") { if (options.action === "copy") {
await clipboard.write?.(content) await clipboard.write?.(content)
@@ -1017,10 +1026,10 @@ export function Session() {
</Show> </Show>
</Match> </Match>
<Match when={forms().length > 0}> <Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? <FormPrompt form={form} /> : null return form ? <FormPrompt form={form} forms={forms()} /> : null
}} }}
</Show> </Show>
</Match> </Match>
+3 -47
View File
@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
export type DialogExportOptionsProps = { export type DialogExportOptionsProps = {
defaultThinking: boolean 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 onCancel?: () => void
} }
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export" type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) { export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog() const dialog = useDialog()
@@ -22,7 +22,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
format: "markdown" as ExportFormat, format: "markdown" as ExportFormat,
thinking: props.defaultThinking, thinking: props.defaultThinking,
sanitize: false,
active: "markdown" as Active, active: "markdown" as Active,
}) })
@@ -31,7 +30,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
action, action,
format: store.format, format: store.format,
thinking: store.thinking, thinking: store.thinking,
sanitize: store.sanitize,
}) })
const activate = () => { const activate = () => {
@@ -40,7 +38,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return return
} }
if (store.active === "thinking") setStore("thinking", !store.thinking) 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) if (store.active === "copy" || store.active === "export") confirm(store.active)
} }
@@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const order: Active[] = const order: Active[] =
store.format === "markdown" store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"] ? ["markdown", "json", "thinking", "copy", "export"]
: ["markdown", "json", "sanitize", "copy", "export"] : ["markdown", "json", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
}, },
}, },
@@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text> </text>
</box> </box>
</Show> </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 flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box <box
paddingLeft={4} paddingLeft={4}
@@ -229,7 +186,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
action: "copy" | "export" action: "copy" | "export"
format: ExportFormat format: ExportFormat
thinking: boolean thinking: boolean
sanitize: boolean
} | null>((resolve) => { } | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
+1 -2
View File
@@ -84,8 +84,7 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}> <box gap={1}>
{props.description?.()} {props.description?.()}
<textarea <textarea
height={1} height={3}
wrapMode="none"
ref={(val: TextareaRenderable) => { ref={(val: TextareaRenderable) => {
textarea = val textarea = val
setTextareaTarget(val) setTextareaTarget(val)
+1 -14
View File
@@ -71,7 +71,6 @@ export interface DialogSelectOption<T = any> {
detailsColor?: RGBA detailsColor?: RGBA
detailsWrap?: boolean detailsWrap?: boolean
footer?: JSX.Element | string footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number titleWidth?: number
truncateTitle?: boolean | "left" truncateTitle?: boolean | "left"
category?: string category?: string
@@ -728,7 +727,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={ footer={
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
} }
footerColor={option.footerColor}
titleWidth={option.titleWidth} titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle} truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined} description={option.description !== category ? option.description : undefined}
@@ -786,7 +784,6 @@ function Option(props: {
current?: boolean current?: boolean
muted?: boolean muted?: boolean
footer?: JSX.Element | string footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number titleWidth?: number
truncateTitle?: boolean | "left" truncateTitle?: boolean | "left"
gutter?: () => JSX.Element gutter?: () => JSX.Element
@@ -835,17 +832,7 @@ function Option(props: {
</text> </text>
<Show when={props.footer}> <Show when={props.footer}>
<box flexShrink={0}> <box flexShrink={0}>
<text <text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
fg={
props.active && !props.muted
? text()
: props.muted && (props.active || props.current)
? theme.text.subdued
: (props.footerColor ?? theme.text.subdued)
}
>
{props.footer}
</text>
</box> </box>
</Show> </Show>
</> </>
+1 -1
View File
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
open(props.href).catch(() => {}) open(props.href).catch(() => {})
}} }}
> >
<a href={props.href}>{displayText}</a> {displayText}
</text> </text>
) )
} }
+25 -3
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80) { async function mountForm(root: string, width = 80, coalesce = false) {
const state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
const events = createEventStream() const events = createEventStream()
const transport = createFetch( const transport = createFetch(
(url, request) => (url, request) =>
url.pathname === "/api/session/ses_test/form/frm_test/reply" /^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
? request.json().then((answer) => { ? request.json().then((answer) => {
replies.push(answer) replies.push(answer)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
id: "frm_test", id: "frm_test",
sessionID: "ses_test", sessionID: "ses_test",
title: "Authorization required", title: "Authorization required",
...(coalesce ? { coalesce: "authorization" } : {}),
fields: [ fields: [
{ {
key: "authorization", key: "authorization",
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
<ClientProvider api={createApi(transport.fetch)}> <ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}> <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider> <ToastProvider>
<FormPrompt form={form} /> <FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</ClientProvider> </ClientProvider>
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy() prompt.app.renderer.destroy()
} }
}) })
test("replies to every coalesced form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, true)
try {
prompt.app.mockInput.pressKey("right")
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
prompt.app.mockInput.pressKey("left")
prompt.app.mockInput.pressKey("c")
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
} finally {
prompt.app.renderer.destroy()
}
})
+1 -12
View File
@@ -3,7 +3,6 @@ import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Schema } from "effect" import { Schema } from "effect"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config" import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
import { settings } from "../src/component/dialog-config"
test("validates mini replay settings", () => { test("validates mini replay settings", () => {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
@@ -18,10 +17,7 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => { test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({ expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
tabs: { enabled: true, layout: "vertical" },
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
}) })
@@ -42,13 +38,6 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true }) expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" }) expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true }) expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
})
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")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
}) })
test("provides config and its host interface", async () => { test("provides config and its host interface", async () => {
@@ -60,8 +60,8 @@ async function renderSessionTabs(
await Bun.write( await Bun.write(
file, file,
JSON.stringify({ JSON.stringify({
global: { tabs: [], unread: {} }, global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
cwd: { [directory]: { 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") const setup = await renderSessionTabs("first")
try { try {
const file = path.join(setup.state, "test", "tui", "tabs.json") const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0) await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({ expect(await Bun.file(file).json()).toEqual({
global: { tabs: [], unread: {} }, global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } }, cwd: {},
}) })
} finally { } finally {
setup.destroy() 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 titled.data.session.sync("shared")
await wait(async () => { await wait(async () => {
if (!(await Bun.file(file).exists())) return false 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 observed = ["Generated title"]
const pending = new Set<Promise<void>>() 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) const read = Bun.file(file)
.json() .json()
.then((value) => { .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) if (title && observed.at(-1) !== title) observed.push(title)
}) })
.catch(() => undefined) .catch(() => undefined)
@@ -81,8 +81,9 @@ Add `compaction` to any [OpenCode configuration file](/config):
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"compaction": { "compaction": {
"auto": true, "auto": true,
"prune": false,
"keep": { "keep": {
"tokens": 15000 "tokens": 8000
}, },
"buffer": 20000 "buffer": 20000
} }
@@ -92,7 +93,8 @@ Add `compaction` to any [OpenCode configuration file](/config):
| Field | Default | V2 behavior | | Field | Default | V2 behavior |
| --- | ---: | --- | | --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. | | `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. | | `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | 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. | | `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. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens` `keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
@@ -133,6 +135,8 @@ behavior.
## Current limitations ## 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. - Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model. There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside - Summary generation can fail if the summary prompt itself cannot fit beside
+8 -16
View File
@@ -246,27 +246,19 @@ Runtime hooks intercept live operations:
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` | | `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch | | `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | | `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests and responses. They apply to native models; AI HTTP hooks can modify requests, inspect responses, retry, or return a
SDK models do not currently pass through these hooks. Request and response response without calling the provider. It applies to native models; AI SDK
bodies are one-shot streams. Use `clone()` when you intentionally need a models do not currently pass through this hook.
separate reader, but be aware that its slower branch may buffer data. To inspect
or modify chunks while preserving streaming, replace the body with one piped
through a `TransformStream`.
```ts ```ts
await ctx.session.hook("http.request", (event) => { await ctx.session.hook("http", (event) => {
event.request.headers.set("x-session-id", event.sessionID) event.use((request, next) => {
}) request.headers.set("x-session-id", event.sessionID)
return next(request)
await ctx.session.hook("http.response", (event) => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
}) })
}) })
``` ```

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