Compare commits

...
61 changed files with 607 additions and 312 deletions

No files matched your search

+36 -36
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.22",
"version": "2.0.23",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -179,7 +179,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -205,7 +205,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -254,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -281,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -322,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -410,7 +410,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -553,7 +553,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -572,7 +572,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -585,7 +585,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -599,7 +599,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -614,7 +614,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -653,7 +653,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -683,7 +683,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -698,7 +698,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -722,7 +722,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -743,7 +743,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -765,7 +765,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -800,7 +800,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -820,7 +820,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -854,7 +854,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -873,7 +873,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -919,7 +919,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -933,7 +933,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -967,7 +967,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1002,7 +1002,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1040,7 +1040,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1081,7 +1081,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.22",
"version": "2.0.23",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -54,6 +54,7 @@ const route = Route.make({
),
},
stream: AnthropicMessages.protocol.stream,
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
+15 -7
View File
@@ -293,20 +293,28 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
const opus5 = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
model: vertex.model("claude-opus-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const opus48 = yield* compileRequest(
LLM.request({
model: vertex.model("claude-opus-4-8"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
expect(opus5.body.output_config).toEqual({ effort: "high" })
expect(systemMessages(opus48.body)).toHaveLength(0)
expect(opus48.body.output_config).toEqual({ effort: "low" })
}),
)
})
@@ -650,6 +650,43 @@ test.describe("Working", () => {
})
}
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
["one", "two", "three"].map((name) =>
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
),
{ completed: false },
),
],
settings: { timelineDetail: timelinePresets[0].value },
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="read-tool-group"]')
const shimmer = group.locator('[data-component="text-shimmer"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
await expect(group).toHaveAttribute(
"data-timeline-part-ids",
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
)
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(working).toBeVisible()
})
for (const grouped of [false, true]) {
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
await setupTimeline(page, {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.22",
"version": "2.0.23",
"description": "",
"type": "module",
"exports": {
@@ -374,8 +374,9 @@ function MessageTimelineView(
return message?.type === "compaction" && message.status === "running"
}
// Used groups keep the fallback regardless of disclosure state.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
return false
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"bin": {
+28 -30
View File
@@ -1,13 +1,13 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode/client/effect/service"
import { Service } from "@opencode/client/effect/service"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schema } from "effect"
import { PersistentPty } from "@opencode/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
@@ -64,11 +64,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const incumbent =
const findIncumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
yield* RetainedImage.retain(global.cache, "service")
@@ -88,7 +88,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
const launch = start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
@@ -144,22 +144,29 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
transform,
).pipe(
)
const server = yield* launch.pipe(
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found
? Effect.void
: Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
),
),
)
if (findIncumbent === undefined || !addressInUse(error)) return Effect.fail(error)
return Effect.gen(function* () {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
const found = yield* findIncumbent.pipe(Effect.timeoutOption(deadline - Date.now()))
if (Option.isSome(found) && found.value !== undefined) return
yield* Effect.sleep("100 millis")
if (Date.now() >= deadline) break
// Failed binds close their scope; a successful bind may take longer than this window to boot.
const server = yield* launch.pipe(Effect.catchIf(addressInUse, () => Effect.void))
if (server !== undefined) return server
}
return yield* Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
)
})
}),
)
if (server === undefined) return
@@ -175,15 +182,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
})
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.spaced("100 millis")),
Effect.timeoutOption("15 seconds"),
)
return Option.isSome(found)
})
function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
+3 -1
View File
@@ -58,8 +58,10 @@ function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
// qr-scanner decodes in a worker it creates from a blob: URL whenever the browser has no BarcodeDetector (Safari, desktop
// Chrome on Windows and Linux). Without worker-src that worker falls under script-src 'self' and never starts.
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:; worker-src 'self' blob:`
}
function cspForHtml(body: string) {
+72 -1
View File
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
import { Global } from "@opencode/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Schema } from "effect"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -403,6 +403,77 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
}
}, 30_000)
test("the original managed service contender binds when the occupied port is released", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-retry-"))
const recognizing = Promise.withResolvers<void>()
const requests: string[] = []
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
requests.push(new URL(request.url).pathname)
if (requests.length === 2) recognizing.resolve()
return Response.json({ unrelated: true })
},
})
const port = listener.port
if (port === undefined) throw new Error("Server did not bind a port")
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({
id: "stale",
version: OPENCODE_VERSION,
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: isolatedEnv(root),
stderr: "pipe",
stdout: "ignore",
})
const stderr = new Response(contender.stderr).text()
try {
// The first probe is preflight; the second only happens after start() fails to bind.
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
expect(requests).toEqual(["/api/info", "/api/info"])
await listener.stop(true)
const info = await Promise.race([
waitForInfo(registration, (info) => info.pid === contender.pid),
contender.exited.then(() => undefined),
])
expect(info?.pid, contender.exitCode === null ? undefined : await stderr).toBe(contender.pid)
const endpoint = await Effect.runPromise(
Service.discover({ file: registration }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry({ times: 400, schedule: Schedule.spaced("50 millis") }),
Effect.provide(NodeFileSystem.layer),
),
)
expect(new URL(endpoint.url).port).toBe(String(port))
expect(
await fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }).then((response) =>
response.json(),
),
).toMatchObject({ pid: contender.pid, version: OPENCODE_VERSION, urls: [endpoint.url] })
expect(contender.exitCode).toBe(null)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await waitForExit(contender)).toBe(true)
expect(await Bun.file(registration).exists()).toBe(false)
await expectPortAvailable(port)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_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>()
+1
View File
@@ -171,6 +171,7 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
expect(fallback.headers.get("content-security-policy")).toContain("worker-src 'self' blob:")
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+27 -100
View File
@@ -1,7 +1,5 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -11,8 +9,10 @@ import {
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
export * from "../service.js"
export { headers }
/** Contents of the local service registration file. */
export type Info = import("../service.js").Info
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
options: DiscoverOptions & { readonly url: string },
) {
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined) return undefined
if (!found.compatible) return undefined
if (info === undefined) return undefined
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
if (!found?.compatible) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
@@ -58,6 +58,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -89,6 +90,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
for (const item of contenders) {
if (item.child.pid === info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -117,19 +125,27 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
contenders.add(yield* spawnContender)
lastSpawn = Date.now()
@@ -145,7 +161,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
if (Option.isNone(found))
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
})
@@ -161,17 +177,6 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
})
function fallback() {
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
return join(state, "opencode", "service.json")
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
}
/** Schema for the local service registration file. */
export const Info = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -182,13 +187,6 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeInfo = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}),
)
// A missing or corrupt file means no valid info; callers treat both
// the same (the registering server self-evicts, clients rediscover).
const read = Effect.fnUntraced(function* (file?: string) {
@@ -198,77 +196,10 @@ const read = Effect.fnUntraced(function* (file?: string) {
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
})
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
const probe = Effect.fnUntraced(function* (info: Info) {
return (yield* probeResult(info)).service
})
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
info.password === undefined
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready" as const,
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const body = result.value.body
const serverInfo = decodeInfo(body)
if (Option.isSome(serverInfo)) {
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.value.version !== info.version)
return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
return { service: undefined, timedOut: false }
})
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* probeResult(info, timeout)) }
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
@@ -287,10 +218,6 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
+22 -89
View File
@@ -1,6 +1,4 @@
import { readFile, rm } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
@@ -11,8 +9,10 @@ import {
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, probeResult, same } from "../service-probe.js"
export * from "../service.js"
export { headers }
// Find, start, and stop the local opencode background service.
//
@@ -38,6 +38,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -56,7 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
try {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
@@ -68,6 +69,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
for (const item of contenders) {
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -95,19 +103,27 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
contenders.add(await spawnContender())
lastSpawn = Date.now()
@@ -132,18 +148,6 @@ export async function stop(options: StopOptions = {}) {
if (info !== undefined) await terminate(info, options, ensureTiming(options))
}
function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return {
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
}
}
async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
@@ -154,73 +158,6 @@ async function read(file?: string) {
}
}
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
info.password === undefined
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready" as const,
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const serverInfo = decodeInfo(result.value.body)
if (serverInfo !== undefined) {
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.version !== info.version)
return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
return { service: undefined, timedOut: false }
}
function decodeInfo(input: unknown) {
if (typeof input !== "object" || input === null) return
if (!("version" in input) || typeof input.version !== "string") return
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
return { version: input.version, pid: input.pid }
}
async function registered(file?: string, timeout?: number) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
@@ -250,10 +187,6 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
return false
}
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
+84
View File
@@ -0,0 +1,84 @@
import { homedir } from "node:os"
import { join } from "node:path"
import type { Endpoint, Info } from "./service.js"
import { defaultEnsureTiming } from "./service-timing.js"
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
// Promise client never loads the Effect runtime.
export type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
/** The default registration file. */
export function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
}
/** Whether two registrations describe the same service instance. */
export function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
/** Ask a registered owner for its health and classify the answer. */
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.catch(() => undefined)
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
const response = result.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready",
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const serverInfo = decodeInfo(result.body)
if (serverInfo === undefined) return { service: undefined, timedOut: false }
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return {
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
}
}
function decodeInfo(input: unknown) {
if (typeof input !== "object" || input === null) return
if (!("version" in input) || typeof input.version !== "string") return
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
return { version: input.version, pid: input.pid }
}
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises"
import { mkdtemp, rename, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { waitForExit } from "./service-timing"
@@ -8,6 +8,16 @@ export async function serviceFixture() {
const registration = join(directory, "service.json")
const processes: Bun.Subprocess[] = []
const pids = new Set<number>()
const starts = async () =>
(
await Bun.file(registration + ".starts")
.text()
.catch(() => "")
)
.trim()
.split("\n")
.filter(Boolean)
.map(Number)
const command = (mode: string, ...args: string[]) => [
process.execPath,
join(import.meta.dir, "service.ts"),
@@ -20,6 +30,21 @@ export async function serviceFixture() {
directory,
registration,
command,
starts,
async waitForStarts(count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const result = await starts()
if (result.length >= count) return result
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} contenders`)
},
async release(pid: number, action: "fail" | "hang" | "ready") {
const file = registration + `.release-${pid}`
await Bun.write(file + ".tmp", action)
await rename(file + ".tmp", file)
if (action === "fail") await waitForExit(pid)
},
spawn(mode: string, ...args: string[]) {
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
processes.push(subprocess)
@@ -47,6 +72,8 @@ export async function serviceFixture() {
throw new Error(`Timed out waiting for ${file}`)
},
async [Symbol.asyncDispose]() {
// Include detached contenders that never registered, even when an assertion fails.
for (const pid of await starts()) pids.add(pid)
await Promise.all([
...processes.map(async (subprocess) => {
subprocess.kill("SIGTERM")
+17 -3
View File
@@ -17,6 +17,18 @@ if (mode === "environment") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
let controlled = ""
if (mode === "controlled") {
await appendFile(registration + ".starts", process.pid + "\n")
const release = registration + `.release-${process.pid}`
while (!(await Bun.file(release).exists())) await Bun.sleep(5)
controlled = await Bun.file(release).text()
if (controlled === "fail") {
process.stderr.write("actionable startup failure: storage initialization denied\n")
process.exit(23)
}
}
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
@@ -63,7 +75,7 @@ const server = Bun.serve({
return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
if (mode === "hanging" || controlled === "hang") {
await appendFile(registration + ".requests", process.pid + "\n")
return new Promise<Response>(() => {})
}
@@ -92,8 +104,10 @@ const server = Bun.serve({
})
// Install handlers before publishing: a test may signal as soon as the registration appears.
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
if (controlled !== "hang") {
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
}
await writeFile(
registration + ".tmp",
@@ -112,6 +112,76 @@ test("reports a failed registered service", async () => {
)
})
test("reports overlapping contender failures without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
(error: unknown) => error,
)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
await Bun.sleep(450)
await fixture.release(second, "fail")
const error = await pending
expect(Date.now() - started).toBeLessThan(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
})
test("retains a contender failure until the deadline while its survivor stalls", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
(error: unknown) => error,
)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
const error = await pending
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
expect(() => process.kill(second, 0)).not.toThrow()
})
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
await Bun.sleep(450)
await fixture.release(second, "ready")
const endpoint = await pending
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
expect(await fixture.starts()).toHaveLength(2)
})
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
await using fixture = await serviceFixture()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(second, "hang")
await fixture.waitForFile()
await fixture.release(first, "fail")
const [, , third] = await fixture.waitForStarts(3)
await fixture.release(third, "ready")
const endpoint = await pending
fixture.track(third)
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
})
test("reports a bounded contender stderr tail with native promises", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+70
View File
@@ -290,6 +290,76 @@ test("reports a contender that fails to start", async () => {
).rejects.toThrow("Server process exited with code 1")
})
test("reports overlapping contender failures without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = run(
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
).catch((error: unknown) => error)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
await Bun.sleep(450)
await fixture.release(second, "fail")
const error = await pending
expect(Date.now() - started).toBeLessThan(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
})
test("retains a contender failure until the deadline while its survivor stalls", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = run(
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
).catch((error: unknown) => error)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
const error = await pending
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
expect(() => process.kill(second, 0)).not.toThrow()
})
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
await Bun.sleep(450)
await fixture.release(second, "ready")
const endpoint = await pending
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
expect(await fixture.starts()).toHaveLength(2)
})
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
await using fixture = await serviceFixture()
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(second, "hang")
await fixture.waitForFile()
await fixture.release(first, "fail")
const [, , third] = await fixture.waitForStarts(3)
await fixture.release(third, "ready")
const endpoint = await pending
fixture.track(third)
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
})
test("reports a bounded contender stderr tail", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.22",
"version": "2.0.23",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.22",
"version": "2.0.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.22",
"version": "2.0.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.22",
"version": "2.0.23",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+3 -1
View File
@@ -408,7 +408,9 @@ export const layer = Layer.effect(
Effect.flatMap((model) =>
model && hasPackage(model)
? Effect.succeed(model)
: Effect.map(models.available(), (models) => models.find(hasPackage)),
: Effect.map(models.available(), (models) =>
models.find((model) => hasPackage(model) && Model.supportsText(model)),
),
),
)
if (!selected) return undefined
+8 -1
View File
@@ -261,7 +261,7 @@ const layer = Layer.effect(
const value = yield* read()
const requested = value.data.defaultModel
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
return model?.enabled ? model : value.available[0]
return model?.enabled ? model : value.available.find(supportsText)
}),
small: Effect.fn("Model.small")(function* (providerID) {
const value = yield* read()
@@ -282,6 +282,13 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
export function supportsText(model: Pick<Info, "capabilities">) {
return (
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
)
}
export function compatibility(input: unknown): Compatibility | undefined {
if (typeof input === "string") return { reasoningField: input }
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
+2
View File
@@ -3,6 +3,7 @@ import { Integration } from "@opencode/schema/integration"
import { Provider } from "@opencode/schema/provider"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Model } from "../model.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
@@ -100,6 +101,7 @@ function snapshots(data: readonly ModelsDev.Snapshot[]) {
models: provider.models.filter(
(model) =>
model.status !== "deprecated" &&
Model.supportsText(model) &&
!(
provider.info.id === Provider.ID.amazonBedrock &&
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
+1
View File
@@ -244,6 +244,7 @@ export const layer = Layer.effect(
// A compaction just completed; let the runner rebuild the request from it first.
const last = messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
if (!previousCompaction(messages) && !messages.some((message) => message.type === "assistant")) return false
// An encrypted native window estimates as nothing, so wait for a response to measure it.
const measured = messages.findLastIndex((message) => hasMeasuredPrompt(message, context.model.ref))
if (measured < messages.findLastIndex(SessionProviderContext.isCheckpoint)) return false
+10
View File
@@ -553,6 +553,16 @@ describe("Provider and Model", () => {
editor.models.update(providerID, Model.ID.make("new"), (model) => {
model.time.released = 2000
})
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
model.capabilities.input = ["text", "image"]
model.capabilities.output = ["video"]
model.time.released = 3000
})
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
model.capabilities.input = ["audio"]
model.capabilities.output = ["text"]
model.time.released = 4000
})
})
expect((yield* models.default())?.id).toMatch("new")
+21 -2
View File
@@ -624,7 +624,7 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated model definitions", () =>
it.effect("omits deprecated and non-text model definitions", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const providers = yield* Provider.Service
@@ -632,11 +632,13 @@ describe("ModelsDevPlugin", () => {
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const videoID = Model.ID.make("video-gen")
const sttID = Model.ID.make("transcribe")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: [], output: [] },
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
@@ -662,6 +664,21 @@ describe("ModelsDevPlugin", () => {
name: "Legacy",
status: "deprecated" as const,
},
{
id: videoID,
...model,
modelID: videoID,
name: "Video Gen",
capabilities: { tools: false, input: ["text", "image"], output: ["video"] },
limit: { context: 1_024, output: 0 },
},
{
id: sttID,
...model,
modelID: sttID,
name: "Transcribe",
capabilities: { tools: false, input: ["audio"], output: ["text"] },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
@@ -684,6 +701,8 @@ describe("ModelsDevPlugin", () => {
yield* activate(providers)
expect(yield* modelState.get(providerID, activeID)).toBeDefined()
expect(yield* modelState.get(providerID, deprecatedID)).toBeUndefined()
expect(yield* modelState.get(providerID, videoID)).toBeUndefined()
expect(yield* modelState.get(providerID, sttID)).toBeUndefined()
}),
)
+10
View File
@@ -3220,6 +3220,16 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
})
scenario("does not auto-compact the initial prompt before any assistant response", function* (s) {
s.currentModel = compactModel
yield* s.llm.push(TestLLM.text("Direct answer", "text-initial-over-ceiling"))
yield* s.runPrompt("Initial oversized request ".repeat(200))
expect(s.requests).toHaveLength(1)
expect(userTexts(s.requests[0])).toEqual(["Initial oversized request ".repeat(200)])
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
})
scenario("stops after required automatic compaction fails", function* (s) {
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
yield* s.runPrompt("Earlier question ".repeat(180))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.22",
"version": "2.0.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/gui-extensions",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.22",
"version": "2.0.23",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.22",
"version": "2.0.23",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.22",
"version": "2.0.23",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.22",
"version": "2.0.23",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.22",
"version": "2.0.23",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.22",
"version": "2.0.23",
"private": true,
"type": "module",
"scripts": {