Compare commits

..

7 Commits

Author SHA1 Message Date
Ryan Vogel c7e9bcc2bc feat(tui): add server switcher 2026-07-18 19:11:07 +00:00
Aiden Cline 33f1b269e9 fix(codemode): stop leaking undefined into tool arguments (#37652) 2026-07-18 13:42:16 -05:00
opencode-agent[bot] 0d68b0bb20 fix(tui): style interrupted compaction neutrally (#37655)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 13:41:01 -05:00
Kit Langton 4e56998d3c fix(cli): harden managed service election (#37645) 2026-07-18 14:16:55 -04:00
Aiden Cline d625bc86fc feat(core): publish pending MCP status during connect (#37605) 2026-07-18 12:02:21 -05:00
Aiden Cline 5f437a09b0 feat(codemode): native coercion parity in interpreter (#37608) 2026-07-18 12:01:10 -05:00
opencode-agent[bot] 7d4496eafc test(core): stabilize shell progress test (#37643)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 11:59:24 -05:00
44 changed files with 1176 additions and 355 deletions
@@ -46,6 +46,7 @@ export default Runtime.handler(Commands, (input) =>
yield* run({
server: {
endpoint: server.endpoint,
connect: (url, signal) => runServicePromise(ServerConnection.connect(url), { signal }),
service: service
? {
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
+8 -4
View File
@@ -54,7 +54,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}
const password =
options.mode === "service"
? yield* ServiceConfig.password()
? config.password || randomBytes(32).toString("base64url")
: environmentPassword
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
@@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions === undefined
? undefined
: {
onListen: (address, shutdown) => register(address, password, instanceID, serviceOptions.file, shutdown),
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
}).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
@@ -154,8 +158,8 @@ const register = Effect.fnUntraced(function* (
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry(Schedule.max([Schedule.spaced("100 millis"), Schedule.recurs(60)])),
Effect.option,
Effect.retry(Schedule.spaced("100 millis")),
Effect.timeoutOption("15 seconds"),
)
return Option.isSome(found)
})
+20 -21
View File
@@ -21,23 +21,7 @@ export type Resolved = {
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) {
const password = yield* Env.password
const endpoint = {
url: args.server,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return { endpoint } satisfies Resolved
}
if (args.server !== undefined) return { endpoint: yield* connect(args.server) } satisfies Resolved
if (args.standalone) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
@@ -49,6 +33,24 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Resolved
})
export const connect = Effect.fn("cli.server-connection.connect")(function* (url: string) {
const password = yield* Env.password
const endpoint = {
url,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return endpoint
})
function managedService(options: EnsureOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
@@ -61,10 +63,7 @@ function managedService(options: EnsureOptions) {
}
}
const resolveManaged = Effect.fnUntraced(function* (
options: EnsureOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+127 -9
View File
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -212,9 +213,10 @@ test("concurrent service processes elect one server", async () => {
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
await fs.writeFile(config, JSON.stringify({ port }))
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
try {
const info = await waitForInfo(registration)
@@ -225,8 +227,18 @@ test("concurrent service processes elect one server", async () => {
)
expect(exited).toEqual(losers.map(() => true))
const errors = await Promise.all(
losers.map(
async (process) => (await new Response(process.stdout).text()) + (await new Response(process.stderr).text()),
),
)
expect(
losers.map((process) => process.exitCode),
errors.filter(Boolean).join("\n"),
).toEqual(losers.map(() => 0))
expect(winner?.exitCode).toBe(null)
expect(new URL(info.url).port).toBe(String(port))
expect((await Bun.file(config).json()).password).toBe(info.password)
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/health", info.url), {
@@ -244,6 +256,7 @@ test("concurrent service processes elect one server", async () => {
Bun.sleep(10_000).then(() => false),
])
expect(contenderExited).toBe(true)
expect(contender.exitCode).toBe(0)
expect((await waitForInfo(registration)).id).toBe(info.id)
} finally {
contender.kill("SIGTERM")
@@ -265,14 +278,11 @@ test("concurrent service processes elect one server", async () => {
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.map((process) => process.exited))
try {
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
await fs.rm(root, { recursive: true, force: true })
}
}, 120_000)
@@ -281,8 +291,9 @@ test("configured managed service port overrides the channel default", async () =
const port = await availablePort()
const env = serviceEnv(root)
const registration = path.join(root, "state", "opencode", "service-local.json")
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(config, JSON.stringify({ port, password: "" }))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env,
stderr: "pipe",
@@ -291,6 +302,8 @@ test("configured managed service port overrides the channel default", async () =
try {
const info = await waitForInfo(registration)
expect(new URL(info.url).port).toBe(String(port))
expect(info.password).not.toBe("")
expect((await Bun.file(config).json()).password).toBe(info.password)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
@@ -302,7 +315,7 @@ test("configured managed service port overrides the channel default", async () =
test("unrelated managed port occupancy reports an actionable conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
const listener = Bun.serve({ port: 0, fetch: () => new Response("unrelated") })
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
const port = listener.port
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
@@ -326,6 +339,109 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
}
}, 30_000)
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
const recognizing = Promise.withResolvers<void>()
const requests = { count: 0 }
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return new Promise<Response>(() => {})
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
path.join(root, "config", "opencode", "service-local.json"),
JSON.stringify({ port: listener.port }),
)
const stale = {
id: "stale",
version: InstallationVersion,
url: "http://127.0.0.1:1",
pid: process.pid,
password: "stale",
}
await fs.writeFile(registration, JSON.stringify(stale))
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "pipe",
})
try {
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
const exitCode = await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])
expect(exitCode).toBe(1)
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
expect(output).toContain(`Managed service port ${listener.port} on 127.0.0.1 is already in use by another process`)
expect(await Bun.file(registration).json()).toEqual(stale)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_000)
test("port contender recognizes an incumbent registered during the bind race", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-race-"))
const recognizing = Promise.withResolvers<void>()
const requests = { count: 0 }
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.dirname(config), { recursive: true })
await fs.writeFile(config, JSON.stringify({ port: listener.port }))
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
registration,
JSON.stringify({
id: "stale",
version: InstallationVersion,
url: "http://127.0.0.1:1",
pid: 2_147_483_647,
password: "stale",
}),
)
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
await Bun.sleep(8_000)
const info = {
id: "incumbent",
version: InstallationVersion,
url: `http://127.0.0.1:${listener.port}`,
pid: process.pid,
password: "incumbent",
}
await fs.writeFile(registration, JSON.stringify(info))
expect(await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])).toBe(0)
expect(await Bun.file(registration).json()).toEqual(info)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_000)
test("stale dead registration is replaced after binding the selected port", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
const port = await availablePort()
@@ -375,10 +491,12 @@ test("a failed service stays registered and owns the selected port until stopped
try {
const info = await waitForInfo(registration)
await waitForFailed(info)
expect(owner.exitCode).toBe(null)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
expect(contender.exitCode).toBe(0)
expect((await waitForInfo(registration)).id).toBe(info.id)
expect(owner.exitCode).toBe(null)
+19 -33
View File
@@ -163,12 +163,7 @@ export type SessionMessageProviderState7 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type ModelInterleavedField =
| "reasoning"
| "reasoning_content"
| "reasoning_text"
| "reasoning_details"
| (string & {})
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
@@ -1099,7 +1094,7 @@ export type TuiCommandExecute = {
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| (string & {})
| string
}
}
@@ -1406,8 +1401,6 @@ export type SessionToolFailed = {
}
}
export type ModelInterleaved = true | { field: ModelInterleavedField }
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1892,11 +1885,23 @@ export type SessionMessageCompaction =
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type ModelCapabilities = {
tools: boolean
input: Array<string>
output: Array<string>
interleaved?: ModelInterleaved
export type ModelInfo = {
id: string
modelID: string
providerID: string
family?: string
name: string
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type IntegrationOAuthMethod = {
@@ -2026,25 +2031,6 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
family?: string
name: string
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type IntegrationMethod =
| IntegrationOAuthMethod
| IntegrationCommandMethod
+22 -13
View File
@@ -19,7 +19,7 @@ ultimate source of truth.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments remain subject to their schema and the outbound-handling gap listed below.
arguments follow JSON serialization semantics before their schema applies (see the tools section).
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
@@ -80,7 +80,7 @@ ultimate source of truth.
- [x] Expression and block function bodies.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
@@ -157,8 +157,11 @@ ultimate source of truth.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run;
retain null normalization for program results and JSON serialization.
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
arrays densify. Tools never receive `undefined` inside their input object, though a bare `tools.t(undefined)`
argument still reaches schema decoding as `undefined`. Program results keep the stricter
normalization where every `undefined` becomes `null`.
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
## Objects and properties
@@ -210,9 +213,12 @@ ultimate source of truth.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
reject instead of coercing.
- [ ] Native no-argument parity for `match()` and `search()`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
`repeat` still requires a finite non-negative count.
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
arguments must still be a regular expression or string pattern.
## Numbers and Math
@@ -226,13 +232,16 @@ ultimate source of truth.
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
`Number(...)` directly.
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
during property access.
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sumPrecise is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
- [ ] `Math.sumPrecise`.
- [ ] Global coercing `isFinite` and `isNaN`.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
## JSON and console
+1 -1
View File
@@ -44,7 +44,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
message = (value as { message: string }).message
} else {
try {
message = JSON.stringify(copyOut(value)) ?? String(value)
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
} catch {
message = String(value)
}
+1 -1
View File
@@ -52,7 +52,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
logs,
)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
const result = copyOut(copyIn(value, "Execution result"), "nullify") as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
return {
+37 -21
View File
@@ -12,7 +12,7 @@ import {
PromiseNamespace,
UriFunction,
} from "./model.js"
import { rejectCircularInsertion, typeofValue } from "./references.js"
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
@@ -137,21 +137,31 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
return invokeJsonMethod(ref.name, args, node)
}
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
if (containsOpaqueReference(arg)) {
throw new InterpreterRuntimeError(
`String.${name} expects argument ${index + 1} to be a data value.`,
node,
"InvalidDataValue",
)
}
return arg
}
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
const str = (index: number): string => {
const arg = args[index]
if (typeof arg !== "string")
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
return arg
}
const num = (index: number): number => {
const arg = args[index]
if (typeof arg !== "number")
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
return arg
}
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
const rejectRegex = (): void => {
if (args[0] instanceof CodeModeRegExp) {
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
).as("TypeError")
}
}
let result: unknown
switch (name) {
@@ -187,8 +197,11 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
}
case "split": {
if (args.length === 0) {
result = [value]
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
if (args[0] === undefined) {
const requestedLimit = optNum(1)
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
break
}
if (args[0] instanceof CodeModeRegExp) {
@@ -203,12 +216,15 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = value.slice(optNum(0), optNum(1))
break
case "includes":
rejectRegex()
result = value.includes(str(0), optNum(1))
break
case "startsWith":
rejectRegex()
result = value.startsWith(str(0), optNum(1))
break
case "endsWith":
rejectRegex()
result = value.endsWith(str(0), optNum(1))
break
case "indexOf":
@@ -263,7 +279,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
case "repeat": {
const count = num(0)
if (!Number.isFinite(count) || count < 0)
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
result = value.repeat(count)
break
}
@@ -301,6 +317,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
return boundedData(result, `String.${name} result`)
}
export const arrayStatics = new Set(["isArray", "of", "from"])
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
switch (name) {
case "isArray":
@@ -400,11 +418,9 @@ const invokeStringReplacer = <R>(
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
if (typeof pattern !== "string") {
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
}
if (name === "replace") value.replace(pattern, collect)
else value.replaceAll(pattern, collect)
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
if (name === "replace") value.replace(search, collect)
else value.replaceAll(search, collect)
}
return Effect.gen(function* () {
+1 -1
View File
@@ -105,7 +105,7 @@ export class GlobalMethodReference {
}
export class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
}
export class UriFunction {
+73 -15
View File
@@ -10,6 +10,7 @@ import {
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
type GlobalNamespaceName,
getArray,
getBoolean,
getNode,
@@ -34,7 +35,7 @@ import {
UriFunction,
} from "./model.js"
import { caughtErrorValue, constructErrorValue } from "./errors.js"
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
import {
constructPromise,
invokePromiseInstanceMethod,
@@ -46,10 +47,11 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
import { ScopeStack } from "./scope.js"
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
import { dateMethods } from "../stdlib/date.js"
import { mathConstants } from "../stdlib/math.js"
import { dateMethods, dateStatics } from "../stdlib/date.js"
import { jsonStatics } from "../stdlib/json.js"
import { mathConstants, mathMethods } from "../stdlib/math.js"
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
import { promiseStatics } from "../stdlib/promise.js"
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
import { stringMethods, stringStatics } from "../stdlib/string.js"
@@ -57,6 +59,7 @@ import {
urlMethods,
urlProperties,
urlSearchParamsMethods,
urlStatics,
urlWritableProperties,
invokeUriFunction,
uriArgument,
@@ -83,6 +86,32 @@ import {
CodeModeURLSearchParams,
} from "../values.js"
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
Object: objectStatics,
Math: mathMethods,
JSON: jsonStatics,
Array: arrayStatics,
console: consoleMethods,
Date: dateStatics,
URL: urlStatics,
}
const calleeDescription = (callee: AstNode): string => {
if (callee.type === "Identifier") return getString(callee, "name")
if (callee.type === "MemberExpression") {
const object = getNode(callee, "object")
const property = getNode(callee, "property")
const key =
callee.computed !== true && property.type === "Identifier"
? getString(property, "name")
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (object.type === "Identifier" && key !== undefined) return `${getString(object, "name")}.${key}`
}
return "The called value"
}
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (rhs instanceof ErrorConstructorReference) {
const brand = errorBrandName(lhs)
@@ -199,6 +228,8 @@ export class Interpreter<R> {
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
globalScope.set("isFinite", { mutable: false, value: new CoercionFunction("isFinite") })
globalScope.set("isNaN", { mutable: false, value: new CoercionFunction("isNaN") })
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
@@ -1454,10 +1485,23 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(
`'${operator}' requires a data value in CodeMode.`,
argument,
"InvalidDataValue",
)
}
return coerceToNumber(current)
}
if (argument.type === "Identifier") {
return Effect.sync(() => {
const name = getString(argument, "name")
const current = Number(this.scopes.get(name, argument))
const current = operand(this.scopes.get(name, argument))
const next = current + increment
this.scopes.set(name, next, argument)
return prefix ? next : current
@@ -1466,7 +1510,7 @@ export class Interpreter<R> {
if (argument.type === "MemberExpression") {
return this.modifyMember(argument, (current) => {
const value = Number(current)
const value = operand(current)
const next = value + increment
return Effect.succeed({ write: true, next, result: prefix ? next : value })
})
@@ -1563,6 +1607,9 @@ export class Interpreter<R> {
callable.settle(args[0])
return undefined
}
if (callable === undefined || callable === null) {
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
}
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
})
}
@@ -1833,16 +1880,18 @@ export class Interpreter<R> {
}
if (objectValue instanceof GlobalNamespace) {
if (typeof key !== "string" || isBlockedMember(key)) {
throw new InterpreterRuntimeError(
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
propertyNode,
)
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Math" && mathConstants.has(key)) {
return new ComputedValue((Math as unknown as Record<string, number>)[key])
}
return new GlobalMethodReference(objectValue.name, key)
if (globalStaticMembers[objectValue.name]?.has(key)) {
return new GlobalMethodReference(objectValue.name, key)
}
// Unknown static members read as undefined so feature detection works like native JS.
return new ComputedValue(undefined)
}
if (typeof objectValue === "string") {
@@ -1858,12 +1907,21 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
if (objectValue instanceof CoercionFunction) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Number" && numberConstants.has(key)) {
return new ComputedValue((Number as unknown as Record<string, number>)[key])
}
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
if (objectValue.name === "Number" && numberStatics.has(key)) {
return new GlobalMethodReference("Number", key)
}
if (objectValue.name === "String" && stringStatics.has(key)) {
return new GlobalMethodReference("String", key)
}
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeDate) {
+1 -1
View File
@@ -89,7 +89,7 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
if (value === undefined) return undefined
if (containsRuntimeReference(value)) return undefined
const columns = copyOut(copyIn(value, "console.table columns"), true)
const columns = copyOut(copyIn(value, "console.table columns"), "nullify")
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
}
+2
View File
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
"getTimezoneOffset",
])
export const dateStatics = new Set(["now", "parse", "UTC"])
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
switch (name) {
case "now":
+3 -1
View File
@@ -2,6 +2,8 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
import { typeofValue } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
export const jsonStatics = new Set(["parse", "stringify"])
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
switch (name) {
case "stringify": {
@@ -16,7 +18,7 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
}
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value"), "json"), null, indent)
}
case "parse": {
const text = args[0]
+2
View File
@@ -6,6 +6,8 @@ import { boundedData, coerceToString } from "./value.js"
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const requireObject = (): Record<string, unknown> => {
const input = args[0]
+2
View File
@@ -19,6 +19,8 @@ export const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof CodeModeRegExp) return arg.regex
if (typeof arg === "string") {
try {
+14 -1
View File
@@ -61,10 +61,19 @@ export const coerceToString = (value: unknown): string => {
export const coerceToNumber = (value: unknown): number => {
if (value instanceof CodeModeDate) return value.time
if (isCodeModeValue(value)) return Number.NaN
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
// Arrays coerce through our own string coercion: host Number(array) joins with host
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
if (Array.isArray(value)) return Number(coerceToString(value))
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
}
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
if (ref.name === "Number") return 0
if (ref.name === "String") return ""
}
const raw = args[0]
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
@@ -72,12 +81,16 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(raw))
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(raw))
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
const value = boundedData(raw, `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(value))
if (ref.name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
+20 -8
View File
@@ -118,8 +118,7 @@ export class ToolRuntimeError extends Error {
}
}
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> =>
isToolDefinition<R>(value)
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
@@ -257,18 +256,31 @@ const copyBounded = (
return copied
}
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
if (value === undefined && undefinedAsNull) return null
// "json" mirrors JSON.stringify (undefined object values drop, undefined array elements become
// null, a bare undefined passes through): use it wherever data leaves as JSON, like tool
// arguments and stringify-style formatting. "nullify" turns every undefined, including a bare
// one, into null: use it for program results, where the consumer must never see undefined.
export type CopyOutMode = "json" | "nullify"
export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
if (value === undefined && mode === "nullify") return null
if (typeof value === "number" && !Number.isFinite(value)) {
return null
}
if (Array.isArray(value)) {
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
return Array.from(value, (item) => copyOut(item, undefinedAsNull))
return Array.from(value, (item) => {
const copied = copyOut(item, mode)
return copied === undefined && mode === "json" ? null : copied
})
}
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
return Object.fromEntries(
Object.entries(value)
.map(([key, item]) => [key, copyOut(item, mode)] as const)
.filter(([, item]) => !(item === undefined && mode === "json")),
)
}
return value
@@ -696,13 +708,13 @@ export const make = <R>(
invokeDefinition(
"search",
searchTool,
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
),
),
invoke: (path, args) =>
Effect.gen(function* () {
const name = canonicalSegments(path).join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
const tool = resolve(root, path)
return yield* invokeDefinition(name, tool, externalArgs)
}),
+50
View File
@@ -453,6 +453,56 @@ describe("CodeMode schema flexibility", () => {
expect(observed).toStrictEqual([{ id: 42 }])
})
test("outbound tool arguments follow JSON serialization semantics", async () => {
const observed: Array<unknown> = []
const call = Tool.make({
description: "Observe raw input",
input: { type: "object" },
run: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
}),
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
const result = await Effect.runPromise(
runtime.execute(
`return await tools.adapter.call({ q: undefined, limit: 0 / 0, rate: 1 / 0, items: [1, undefined, 2], holes: [1, , 3] })`,
),
)
expect(result.ok).toBe(true)
const received = observed[0] as Record<string, unknown>
expect(received).toStrictEqual({ limit: null, rate: null, items: [1, null, 2], holes: [1, null, 3] })
// The undefined-valued property is dropped like JSON.stringify, not delivered as undefined.
expect(Object.hasOwn(received, "q")).toBe(false)
})
test("dropping undefined values lets optionalKey schemas accept conditional arguments", async () => {
const observed: Array<unknown> = []
const find = Tool.make({
description: "Find things",
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
run: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
}),
})
const runtime = CodeMode.make({ tools: { things: { find } } })
// The `cond ? value : undefined` idiom: optionalKey rejects a present undefined, so the
// JSON boundary must drop the key before the schema decodes.
const result = await Effect.runPromise(
runtime.execute(`return await tools.things.find({ query: undefined, limit: 5 })`),
)
expect(result.ok).toBe(true)
expect(observed).toStrictEqual([{ limit: 5 }])
const search = await Effect.runPromise(runtime.execute(`return (await search({ query: undefined })).items.length`))
expect(search.ok).toBe(true)
})
test("renders JSON Schema outputs and $defs references", async () => {
const lookup = Tool.make({
description: "Look up a user",
+191 -5
View File
@@ -258,11 +258,23 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
expect(ToolRuntime.copyOut(NaN)).toBeNull()
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
expect(ToolRuntime.copyOut(42)).toBe(42)
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
expect(ToolRuntime.copyOut(NaN, "json")).toBeNull()
expect(ToolRuntime.copyOut(Infinity, "json")).toBeNull()
expect(ToolRuntime.copyOut(-Infinity, "nullify")).toBeNull()
expect(ToolRuntime.copyOut(42, "json")).toBe(42)
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] }, "json")).toEqual({ a: null, b: [null, 1] })
})
})
describe("copyOut undefined handling per boundary mode", () => {
test("json mode mirrors JSON.stringify for undefined", () => {
expect(ToolRuntime.copyOut({ q: undefined, keep: 1 }, "json")).toStrictEqual({ keep: 1 })
expect(ToolRuntime.copyOut([1, undefined, 2], "json")).toStrictEqual([1, null, 2])
expect(ToolRuntime.copyOut({ nested: { a: undefined, b: [undefined] } }, "json")).toStrictEqual({
nested: { b: [null] },
})
expect(ToolRuntime.copyOut(undefined, "json")).toBeUndefined()
expect(ToolRuntime.copyOut({ a: undefined }, "nullify")).toStrictEqual({ a: null })
})
})
@@ -669,3 +681,177 @@ describe("destructuring assignment", () => {
expect(err.message).toContain("Property key must be a string or number")
})
})
describe("coercion parity: zero-argument coercion functions", () => {
test("Number() is 0 and String() is empty, unlike their undefined-argument forms", async () => {
expect(await value(`return Number()`)).toBe(0)
expect(await value(`return String()`)).toBe("")
expect(await value(`return Boolean()`)).toBe(false)
expect(await value(`return Number.isNaN(Number(undefined))`)).toBe(true)
expect(await value(`return String(undefined)`)).toBe("undefined")
})
test("parseInt() and parseFloat() stay NaN with no argument", async () => {
expect(await value(`return Number.isNaN(parseInt())`)).toBe(true)
expect(await value(`return Number.isNaN(parseFloat())`)).toBe(true)
})
})
describe("coercion parity: global isFinite and isNaN", () => {
test("coerce their argument like native JS, unlike the Number statics", async () => {
expect(await value(`return isFinite("42")`)).toBe(true)
expect(await value(`return Number.isFinite("42")`)).toBe(false)
expect(await value(`return isNaN("oops")`)).toBe(true)
expect(await value(`return isNaN("42")`)).toBe(false)
expect(await value(`return isFinite(Infinity)`)).toBe(false)
expect(await value(`return isNaN(null)`)).toBe(false)
})
test("zero-argument forms match native", async () => {
expect(await value(`return isFinite()`)).toBe(false)
expect(await value(`return isNaN()`)).toBe(true)
})
test("read as functions", async () => {
expect(await value(`return typeof isFinite`)).toBe("function")
expect(await value(`return typeof isNaN`)).toBe("function")
})
test("work as array callbacks", async () => {
expect(await value(`return [1, "2", "x", Infinity].filter(isFinite)`)).toEqual([1, "2"])
expect(await value(`return ["1", "x"].map(isNaN)`)).toEqual([false, true])
})
})
describe("coercion parity: arrays coerce to numbers through their string form", () => {
test("arrays with objects become NaN instead of crashing on host ToPrimitive", async () => {
expect(await value(`let x = [{}]; x++; return Number.isNaN(x)`)).toBe(true)
expect(await value(`return isFinite([{}])`)).toBe(false)
expect(await value(`return "abc".slice([{}])`)).toBe("abc")
})
test("single-element and empty arrays match native Number()", async () => {
expect(await value(`return Number([5])`)).toBe(5)
expect(await value(`return Number([])`)).toBe(0)
expect(await value(`return Number.isNaN(Number([1, 2]))`)).toBe(true)
})
})
describe("coercion parity: String method arguments coerce like native JS", () => {
test("includes and indexOf coerce numbers", async () => {
expect(await value(`return "v1.2".includes(1)`)).toBe(true)
expect(await value(`return "a2b".indexOf(2)`)).toBe(1)
expect(await value(`return "abc".includes("d")`)).toBe(false)
})
test("slice, repeat, and padStart coerce numeric strings", async () => {
expect(await value(`return "abc".slice("1")`)).toBe("bc")
expect(await value(`return "ab".repeat("2")`)).toBe("abab")
expect(await value(`return "7".padStart("3", 0)`)).toBe("007")
})
test("split coerces separators but treats undefined as absent", async () => {
expect(await value(`return "a1b".split(1)`)).toEqual(["a", "b"])
expect(await value(`return "a,b".split(undefined)`)).toEqual(["a,b"])
expect(await value(`return "a,b".split()`)).toEqual(["a,b"])
expect(await value(`return "a,b".split(undefined, 0)`)).toEqual([])
expect(await value(`return "a,b".split(undefined, 1)`)).toEqual(["a,b"])
})
test("replace coerces search and replacement values", async () => {
expect(await value(`return "a1b".replace(1, 2)`)).toBe("a2b")
expect(await value(`return "a1b".replace(1, () => "x")`)).toBe("axb")
})
test("repeat rejections carry the native RangeError name", async () => {
expect(await value(`try { "a".repeat(-1) } catch (e) { return e.name }`)).toBe("RangeError")
})
test("includes, startsWith, and endsWith reject regular expressions with a TypeError", async () => {
expect(await value(`try { "abc".includes(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
expect(await value(`try { "abc".startsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
expect(await value(`try { "abc".endsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
})
test("opaque runtime references still reject as data errors", async () => {
const err = await error(`const f = () => 1; return "abc".includes(f)`)
expect(err.message).toContain("data value")
const replacerErr = await error(`const f = () => 1; return "a".replace(f, () => "x")`)
expect(replacerErr.message).toContain("data value")
})
})
describe("coercion parity: match() and search() with no argument", () => {
test("behave as an empty pattern like native JS", async () => {
expect(await value(`return "abc".search()`)).toBe(0)
expect(await value(`const m = "abc".match(); return { first: m[0], index: m.index }`)).toEqual({
first: "",
index: 0,
})
})
})
describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
test("numeric strings increment like native JS", async () => {
expect(await value(`let x = "5"; x++; return x`)).toBe(6)
expect(await value(`let x = "5"; return ++x`)).toBe(6)
expect(await value(`const o = { n: "2" }; o.n--; return o.n`)).toBe(1)
})
test("dates increment through their epoch time", async () => {
expect(await value(`let d = new Date(5); d++; return d`)).toBe(6)
})
test("plain data objects become NaN instead of crashing", async () => {
expect(await value(`let x = {}; x++; return Number.isNaN(x)`)).toBe(true)
expect(await value(`const o = { a: {} }; o.a++; return Number.isNaN(o.a)`)).toBe(true)
})
test("opaque runtime references reject with a clear error", async () => {
const err = await error(`let f = () => 1; f++`)
expect(err.message).toContain("data value")
})
})
describe("coercion parity: unknown static members read as undefined", () => {
test("feature detection on missing statics works like native JS", async () => {
expect(await value(`return typeof Math.sumPrecise`)).toBe("undefined")
expect(await value(`return Object.groupBy === undefined`)).toBe(true)
expect(await value(`return RegExp.escape === undefined`)).toBe(true)
expect(await value(`return Number.range === undefined`)).toBe(true)
expect(await value(`return String.raw === undefined`)).toBe(true)
expect(await value(`return isFinite.something === undefined`)).toBe(true)
expect(await value(`return console.group === undefined`)).toBe(true)
expect(await value(`return Date.moment === undefined`)).toBe(true)
expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
expect(await value(`return Map.groupBy === undefined`)).toBe(true)
expect(await value(`return Math.sumPrecise?.([1]) ?? "fallback"`)).toBe("fallback")
})
test("known statics still resolve and run", async () => {
expect(await value(`return typeof Math.max`)).toBe("function")
expect(await value(`return typeof console.log`)).toBe("function")
expect(await value(`return typeof Date.now`)).toBe("function")
expect(await value(`return Math.max(1, 2)`)).toBe(2)
expect(await value(`return URL.canParse("https://example.com")`)).toBe(true)
expect(await value(`return Number.isInteger(3)`)).toBe(true)
expect(await value(`return Number.MAX_SAFE_INTEGER`)).toBe(Number.MAX_SAFE_INTEGER)
})
test("calling an unknown static reports a native-style TypeError", async () => {
expect(await value(`try { Math.sumPrecise([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
"TypeError: Math.sumPrecise is not a function.",
)
expect(await value(`try { Math["sumPrecise"]([1]) } catch (e) { return e.message }`)).toBe(
"Math.sumPrecise is not a function.",
)
})
test("blocked members still throw instead of reading as undefined", async () => {
const err = await error(`return Math.constructor`)
expect(err.message).toContain("not available")
const coercionErr = await error(`return Number.constructor`)
expect(coercionErr.message).toContain("Number.constructor is not available")
})
})
@@ -69,9 +69,6 @@ export const Plugin = define({
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
...(config.capabilities.interleaved !== undefined
? { interleaved: config.capabilities.interleaved }
: {}),
}
}
if (config.variants !== undefined) {
+4
View File
@@ -482,6 +482,10 @@ export const layer = Layer.effect(
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
-6
View File
@@ -12,12 +12,6 @@ export type VariantID = typeof VariantID.Type
export const Family = Model.Family
export type Family = Model.Family
export const InterleavedField = Model.InterleavedField
export type InterleavedField = Model.InterleavedField
export const Interleaved = Model.Interleaved
export type Interleaved = Model.Interleaved
export const Capabilities = Model.Capabilities
export type Capabilities = Model.Capabilities
+1 -2
View File
@@ -46,7 +46,7 @@ type SourceModel = {
readonly reasoning_options?: readonly ReasoningOption[]
readonly temperature?: boolean
readonly tool_call: boolean
readonly interleaved?: ModelV2.Interleaved
readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" }
readonly cost?: Cost
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
@@ -505,7 +505,6 @@ function modelInfo(
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
...(model.interleaved !== undefined ? { interleaved: model.interleaved } : {}),
},
variants: [...(input.variants ?? [])],
time: { released: released(model.release_date) },
+3 -1
View File
@@ -410,7 +410,9 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
+2 -10
View File
@@ -271,16 +271,8 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
: []),
]
const capabilities =
info.tool_call !== undefined ||
info.modalities?.input !== undefined ||
info.modalities?.output !== undefined ||
info.interleaved !== undefined
? {
tools: info.tool_call ?? false,
input: info.modalities?.input ?? [],
output: info.modalities?.output ?? [],
...(info.interleaved !== undefined ? { interleaved: info.interleaved } : {}),
}
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
: undefined
return {
modelID: info.id,
+8 -2
View File
@@ -1,7 +1,6 @@
export * as ConfigProviderV1 from "./provider"
import { Schema } from "effect"
import { Interleaved } from "@opencode-ai/schema/model"
import { PositiveInt } from "../../schema"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
@@ -15,7 +14,14 @@ export const Model = Schema.Struct({
reasoning: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(Interleaved),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Finite,
-18
View File
@@ -289,24 +289,6 @@ describe("Config", () => {
}),
)
it.effect("migrates provider-specific interleaved reasoning fields", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
chat: { interleaved: { field: "vendor_reasoning" } },
},
},
},
})
expect(migrated.providers?.custom?.models?.chat?.capabilities?.interleaved).toEqual({
field: "vendor_reasoning",
})
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
+2 -12
View File
@@ -170,12 +170,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: {
chat: {
name: "First",
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
interleaved: { field: "vendor_reasoning" },
},
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
@@ -256,12 +251,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({
tools: true,
input: ["text"],
output: ["text"],
interleaved: { field: "vendor_reasoning" },
})
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([
+1 -2
View File
@@ -47,7 +47,6 @@ const fixture = {
reasoning: false,
temperature: true,
tool_call: true,
interleaved: { field: "vendor_reasoning" },
limit: { context: 128000, output: 8192 },
},
},
@@ -70,7 +69,7 @@ const fixtureSnapshot = [
family: undefined,
package: undefined,
settings: undefined,
capabilities: { tools: true, input: [], output: [], interleaved: { field: "vendor_reasoning" } },
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [
+29
View File
@@ -1892,6 +1892,35 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("records cancelled manual compaction without surfacing an internal failure", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-interrupt-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
const streamed = yield* Deferred.make<void>()
const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"])
responseStream = Stream.concat(
Stream.fromIterable(partial.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
)
const compaction = yield* session.compact({ sessionID })
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamed)
yield* session.interrupt(sessionID)
yield* Fiber.await(run)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
})
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
const session = yield* setup
+46 -30
View File
@@ -2,7 +2,7 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
const progressOverflowCommand = (bytes: number, release: string) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; while [ ! -e '${release}' ]; do sleep 0.05; done`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
@@ -417,33 +417,49 @@ describe("ShellTool", () => {
),
)
it.live("reports bounded output progress for a running command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
it.live(
"reports bounded output progress for a running command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const release = "shell-progress-release"
const releasePath = path.join(tmp.path, release)
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const observed = yield* Deferred.make<ToolRegistry.Progress>()
yield* settleTool(registry, {
...call(
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
"call-progress",
),
progress: (update) =>
Effect.gen(function* () {
if (update.structured.truncated !== true) return
const content = update.content[0]
if (content?.type !== "text") return
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
return
yield* Deferred.succeed(observed, update)
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
}),
})
expect(progress).toHaveLength(1)
expect(progress[0]?.structured).toEqual({ truncated: true })
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
const progress = yield* Deferred.await(observed)
expect(progress.structured).toEqual({ truncated: true })
const content = progress.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("returns a useful timeout settlement", () =>
+4 -10
View File
@@ -830,7 +830,7 @@ function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, r
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue")
.replaceAll(/(?<!["'])\bunknown\b(?!["'])/g, "any")
return mutable ? mutableType(preserveStringSuggestions(output)) : preserveStringSuggestions(output)
return mutable ? mutableType(output) : output
}
return {
types: document.codes.map((code) => render(code.Type)),
@@ -870,15 +870,9 @@ function structuralType(schema: Schema.Top) {
}
return type
}
return preserveStringSuggestions(
expand(document.codes[0].Type)
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue"),
)
}
function preserveStringSuggestions(type: string) {
return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})")
return expand(document.codes[0].Type)
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue")
}
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
@@ -503,19 +503,6 @@ describe("HttpApiCodegen.generate", () => {
expect(types).not.toContain("Brand")
})
test("preserves suggestions for open string unions in Promise wire types", () => {
const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
identifier: "Field",
})
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'export type Field = "reasoning" | "reasoning_content" | (string & {})',
)
})
test("retains non-recursive references in Promise wire types", () => {
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
const output = emitPromise(
-18
View File
@@ -41,29 +41,11 @@ export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
export type Family = typeof Family.Type
export type InterleavedField =
| "reasoning"
| "reasoning_content"
| "reasoning_text"
| "reasoning_details"
| (string & {})
export const InterleavedField: Schema.Codec<InterleavedField> = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text", "reasoning_details"]),
Schema.String,
]).annotate({ identifier: "Model.InterleavedField" })
export type Interleaved = true | { readonly field: InterleavedField }
export const Interleaved: Schema.Codec<Interleaved> = Schema.Union([
Schema.Literal(true),
Schema.Struct({ field: InterleavedField }),
]).annotate({ identifier: "Model.Interleaved" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
interleaved: Interleaved.pipe(optional),
}).annotate({ identifier: "Model.Capabilities" })
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
-11
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Model } from "../src/model.js"
describe("Model.Ref", () => {
@@ -21,13 +20,3 @@ describe("Model.Ref", () => {
expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow()
})
})
describe("Model.Interleaved", () => {
test("accepts known and provider-specific fields", () => {
const decode = Schema.decodeUnknownSync(Model.Interleaved)
const fields = ["reasoning", "reasoning_content", "reasoning_text", "reasoning_details", "vendor_reasoning"]
for (const field of fields) expect(decode({ field })).toEqual({ field })
expect(decode(true)).toBe(true)
})
})
+137 -77
View File
@@ -81,6 +81,10 @@ import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
import { ServerProvider, decodeServerURLs, useServer, type ServerConnection } from "./context/server"
import { DialogServer } from "./component/dialog-server"
import { readJson, writeJsonAtomic } from "./util/persistence"
import path from "path"
import { DialogVariant } from "./component/dialog-variant"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
@@ -94,6 +98,7 @@ registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
"server.switch",
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -142,6 +147,7 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Endpoint
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
service?: {
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
@@ -182,24 +188,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
terminalSuspend: process.platform !== "win32",
})
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
const initialAPI = OpenCode.make({
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
yield* Effect.tryPromise(() => initialAPI.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.catch(() => Effect.tryPromise(() => initialAPI.location.get())),
)
const serverFile = path.join(global.state, "tui-servers.json")
const serverURLs = decodeServerURLs(yield* Effect.promise(() => readJson<unknown>(serverFile).catch(() => undefined)))
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
const service = managed
? {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
},
restart: managed.restart,
}
: undefined
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const result = yield* Effect.scoped(
Effect.gen(function* () {
@@ -315,68 +313,36 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode={mode}>
<ServerProvider
initial={{ endpoint: input.server.endpoint, service: input.server.service }}
urls={serverURLs}
connect={input.server.connect}
prepare={(endpoint) => {
const api = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
return api.file
.list({ location: { directory: process.cwd() } })
.catch(() => api.location.get())
.then(() => undefined)
}}
save={(servers) => writeJsonAtomic(serverFile, { servers })}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
started={appStarted}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
<ServerScope input={input} pluginRuntime={pluginRuntime} started={appStarted} />
</ServerProvider>
</ThemeProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
@@ -405,6 +371,91 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
type ServerScopeProps = {
input: TuiInput
pluginRuntime: ReturnType<typeof createPluginRuntime>
started: number
}
function ServerScope(props: ServerScopeProps) {
const server = useServer()
let startup = true
return (
<Show when={server.current} keyed>
{(connection) => {
const initial = startup
startup = false
return <ServerApp {...props} connection={connection} startup={initial} />
}}
</Show>
)
}
function ServerApp(props: ServerScopeProps & { connection: ServerConnection; startup: boolean }) {
const api = OpenCode.make({
baseUrl: props.connection.endpoint.url,
headers: Service.headers(props.connection.endpoint),
})
const managed = props.connection.service
const service = managed
? {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
return {
api: OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }),
}
},
restart: managed.restart,
}
: undefined
const args = props.startup ? props.input.args : {}
return (
<ArgsProvider {...args}>
<RouteProvider
initialRoute={
args.continue ? { type: "session", sessionID: "dummy" } : props.startup ? undefined : { type: "home" }
}
>
<PluginRuntimeProvider value={props.pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={props.input.packages}>
<App
started={props.started}
pair={
props.connection.endpoint.auth ?? {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ArgsProvider>
)
}
function App(props: { pair?: DialogPairCredentials; started: number }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
@@ -766,6 +817,15 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
},
category: "System",
},
{
name: "server.switch",
title: "Switch server",
slash: { name: "servers" },
run: () => {
dialog.replace(() => <DialogServer />)
},
category: "System",
},
{
name: "server.pair",
title: "Pair device",
@@ -0,0 +1,47 @@
import { createMemo } from "solid-js"
import { useServer } from "../context/server"
import { DialogSelect } from "../ui/dialog-select"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
export function DialogServer() {
const server = useServer()
const dialog = useDialog()
const toast = useToast()
const options = createMemo(() =>
server.list().map((item) => ({
title: item.name,
description: item.url,
value: item.id,
onSelect: () => {
dialog.clear()
void server
.select(item.id)
.then(() => toast.show({ variant: "success", message: `Switched to ${item.name}` }), toast.error)
},
})),
)
function add() {
void DialogPrompt.show(dialog, "Add server", {
placeholder: "https://devbox.example",
description: () => <text>Enter the URL of an OpenCode V2 server.</text>,
}).then((value) => {
if (!value) return
dialog.clear()
void server
.add(value)
.then(() => toast.show({ variant: "success", message: `Connected to ${server.current.name}` }), toast.error)
})
}
return (
<DialogSelect
title="Switch server"
options={options()}
current={server.current.id}
actions={[{ command: "server.add", title: "Add server", selection: "none", onTrigger: add }]}
/>
)
}
+4
View File
@@ -81,6 +81,8 @@ export const Definitions = {
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
debug_view: keybind("none", "View debug info"),
server_switch: keybind("<leader>w", "Switch server"),
server_add: keybind("ctrl+a", "Add server"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
@@ -283,6 +285,8 @@ export const CommandMap = {
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
debug_view: "opencode.debug",
server_switch: "server.switch",
server_add: "server.add",
session_export: "session.export",
session_copy: "session.copy",
session_move: "session.move",
+126
View File
@@ -0,0 +1,126 @@
import type { Endpoint } from "@opencode-ai/client/effect/service"
import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
export type ServerConnection = {
id: string
name: string
url: string
endpoint: Endpoint
service?: {
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
}
}
export type ServerInfo = Pick<ServerConnection, "id" | "name" | "url">
type ServerContext = {
readonly current: ServerConnection
list: () => ServerInfo[]
select: (id: string) => Promise<void>
add: (url: string) => Promise<void>
}
const context = createContext<ServerContext>()
export function ServerProvider(
props: ParentProps<{
initial: Omit<ServerConnection, "id" | "name" | "url">
urls: string[]
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
prepare: (endpoint: Endpoint) => Promise<void>
save: (urls: string[]) => Promise<void>
}>,
) {
const initialURL = normalizeServerURL(props.initial.endpoint.url)
const initial = {
...props.initial,
id: initialURL,
name: serverName(initialURL),
url: initialURL,
}
const [current, setCurrent] = createSignal(initial)
const [urls, setURLs] = createSignal(
props.urls.map(normalizeServerURL).filter((url, index, all) => url !== initialURL && all.indexOf(url) === index),
)
const list = createMemo(() => [
{ id: initial.id, name: initial.name, url: initial.url },
...urls().map((url) => ({ id: url, name: serverName(url), url })),
])
async function select(id: string) {
if (id === current().id) return
if (id === initial.id) {
setCurrent(initial)
return
}
const info = list().find((item) => item.id === id)
if (!info) throw new Error(`Unknown server: ${id}`)
const endpoint = await props.connect(info.url)
await props.prepare(endpoint)
setCurrent({ ...info, endpoint })
}
async function add(value: string) {
const url = normalizeServerURL(value)
const existing = list().find((item) => item.url === url)
if (existing) return select(existing.id)
const endpoint = await props.connect(url)
await props.prepare(endpoint)
const next = [...urls(), url]
await props.save(next)
setURLs(next)
setCurrent({ id: url, name: serverName(url), url, endpoint })
}
return (
<context.Provider
value={{
get current() {
return current()
},
list,
select,
add,
}}
>
{props.children}
</context.Provider>
)
}
export function useServer() {
const value = useContext(context)
if (!value) throw new Error("Server context must be used within a ServerProvider")
return value
}
export function decodeServerURLs(input: unknown) {
if (!input || typeof input !== "object" || !("servers" in input) || !Array.isArray(input.servers)) return []
return input.servers.flatMap((item) => {
if (typeof item !== "string") return []
try {
return [normalizeServerURL(item)]
} catch {
return []
}
})
}
export function normalizeServerURL(value: string) {
const trimmed = value.trim()
const input = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
if (!URL.canParse(input)) throw new Error(`Invalid server URL: ${trimmed || value}`)
const url = new URL(input)
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Server URL must use HTTP or HTTPS")
if (url.username || url.password) throw new Error("Server URL must not contain credentials")
if (url.search || url.hash) throw new Error("Server URL must not contain a query or fragment")
url.pathname = url.pathname.replace(/\/+$/, "") || "/"
return url.href.replace(/\/$/, "")
}
export function serverName(value: string) {
const url = new URL(value)
if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return "Local"
return url.host
}
+7 -3
View File
@@ -1440,9 +1440,10 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
const ctx = use()
const { themeV2, syntax } = useTheme()
const status = () => props.message.status
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
const cancelled = () => props.message.status === "failed" && props.message.error.type === "aborted"
const text = () => (props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary)
const content = createMemo(() => text().trim())
const color = () => (status() === "failed" ? themeV2.text.feedback.error() : themeV2.text.subdued())
const color = () => (status() === "failed" && !cancelled() ? themeV2.text.feedback.error() : themeV2.text.subdued())
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1454,11 +1455,14 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
<Match when={status() === "failed"}>
<Match when={status() === "failed" && !cancelled()}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>Compaction</text>
<Show when={cancelled()}>
<text fg={color()}>· cancelled</text>
</Show>
</box>
<box border={["top"]} borderColor={color()} flexGrow={1} />
</box>
+2 -2
View File
@@ -28,7 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
server: { endpoint: { url: server.url.toString() } },
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
@@ -100,7 +100,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
server: { endpoint: { url: server.url.toString() } },
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" },
+127
View File
@@ -0,0 +1,127 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { describe, expect, test } from "bun:test"
import { ServerProvider, decodeServerURLs, normalizeServerURL, serverName, useServer } from "../../src/context/server"
describe("TUI servers", () => {
test("normalizes equivalent endpoint URLs", () => {
expect(normalizeServerURL(" https://devbox.example/ ")).toBe("https://devbox.example")
expect(normalizeServerURL("http://localhost:4096///")).toBe("http://localhost:4096")
expect(normalizeServerURL("devbox.example:4096")).toBe("http://devbox.example:4096")
expect(() => normalizeServerURL("https://user:secret@devbox.example")).toThrow("must not contain credentials")
expect(() => normalizeServerURL("https://devbox.example?token=secret")).toThrow("query or fragment")
})
test("labels loopback and remote servers", () => {
expect(serverName("http://127.0.0.1:49374")).toBe("Local")
expect(serverName("https://devbox.example:4096")).toBe("devbox.example:4096")
})
test("decodes only valid persisted URLs", () => {
expect(decodeServerURLs({ servers: ["https://devbox.example", 1, "ftp://nope"] })).toEqual([
"https://devbox.example",
])
expect(decodeServerURLs(null)).toEqual([])
})
})
test("switches only after the next server is prepared", async () => {
let server!: ReturnType<typeof useServer>
const steps: string[] = []
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={["https://devbox.example", "http://localhost:4096/"]}
connect={async (url) => {
steps.push(`connect:${url}`)
return { url }
}}
prepare={async (endpoint) => {
steps.push(`prepare:${endpoint.url}`)
}}
save={async () => {}}
>
<Harness />
</ServerProvider>
))
try {
expect(server.list().map((item) => item.url)).toEqual(["http://localhost:4096", "https://devbox.example"])
await server.select("https://devbox.example")
expect(steps).toEqual(["connect:https://devbox.example", "prepare:https://devbox.example"])
expect(server.current.url).toBe("https://devbox.example")
} finally {
app.renderer.destroy()
}
})
test("persists only the normalized URL after a successful add", async () => {
let server!: ReturnType<typeof useServer>
const writes: string[][] = []
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={[]}
connect={async (url) => ({
url,
auth: { type: "basic", username: "opencode", password: "secret" },
})}
prepare={async () => {}}
save={async (urls) => void writes.push(urls)}
>
<Harness />
</ServerProvider>
))
try {
await server.add("devbox.example:4096/")
expect(writes).toEqual([["http://devbox.example:4096"]])
expect(JSON.stringify(writes)).not.toContain("secret")
expect(server.current.endpoint.auth?.password).toBe("secret")
} finally {
app.renderer.destroy()
}
})
test("keeps the current server when preparing a new endpoint fails", async () => {
let server!: ReturnType<typeof useServer>
let saved = false
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={[]}
connect={async (url) => ({ url })}
prepare={async () => {
throw new Error("unreachable")
}}
save={async () => {
saved = true
}}
>
<Harness />
</ServerProvider>
))
try {
await expect(server.add("https://devbox.example")).rejects.toThrow("unreachable")
expect(server.current.url).toBe("http://localhost:4096")
expect(saved).toBeFalse()
} finally {
app.renderer.destroy()
}
})
+31
View File
@@ -115,3 +115,34 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("server switch has a default global shortcut", async () => {
let shortcut = () => ""
function Harness() {
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "server.switch", run() {} }],
}))
Keymap.createLayer(() => ({
mode: "global",
bindings: ["server.switch"],
}))
shortcut = () => shortcuts.get("server.switch") ?? ""
return <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
expect(shortcut()).toBe("ctrl+x w")
} finally {
app.renderer.destroy()
}
})