mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7e9bcc2bc | |||
| 33f1b269e9 | |||
| 0d68b0bb20 | |||
| 4e56998d3c | |||
| d625bc86fc | |||
| 5f437a09b0 | |||
| 7d4496eafc |
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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
@@ -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 }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user