Compare commits

..
Author SHA1 Message Date
Dax Raad ab3566ab82 feat(server): expose server info endpoint 2026-09-16 21:43:58 -04:00
Dax Raad b642c1d9ea docs: add CLI settings reference 2026-09-16 21:18:35 -04:00
David HillandLukeParkerDev c8cc984aa0 feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-17 00:39:21 +00:00
Luke Parker 5a9448acbb fix(app): retry transport errors wrapped by the client (#49426) 2026-09-17 10:34:52 +10:00
David Hill f6604cd367 feat(desktop): add animated first-launch loading screen (#49408) 2026-09-17 10:02:09 +10:00
Luke Parker acfacede2d fix(app): preserve native clipboard paste (#49424) 2026-09-17 09:47:00 +10:00
Aiden Cline 4a27842fe6 fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) 2026-09-16 16:14:09 -05:00
Aiden Cline d797722187 feat(codemode): carry cause and own data across the error boundary (#49390) 2026-09-16 15:58:05 -05:00
Dax 7390832f13 feat(tui): reload all locations
Add a global location reload endpoint, shared shutdown lifecycle, client resynchronization, and the TUI /reload command.\n\nfrom OpenCode
2026-09-16 16:47:44 -04:00
Kit Langton 7689c3654e fix(tui): hide error hint when MCP Enter starts sign-in (#49403) 2026-09-16 19:33:08 +00:00
Dax 7df0935ada fix(tui): apply model selection on blank submit (#49374)
Apply the selected model on blank Enter and simplify shared prompt submission setup and recovery.\n\nfrom OpenCode
2026-09-16 19:32:45 +00:00
Dax bcd43760df feat(cli): support inline config content (#49399) 2026-09-16 19:18:55 +00:00
Aiden Cline 9073c522ef feat(plugin): add experimental WebSocket send and receive hooks (#49136) 2026-09-16 14:13:33 -05:00
opencode-agent[bot]andrekram1-node 04c296310e fix(core): skip session warming for subagents (#49387)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-16 13:58:40 -05:00
Kit Langton 79d657b8fe fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:43 -04:00
Kit Langton 606ec4fa38 fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:00 -04:00
149 changed files with 2575 additions and 1397 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
+21 -6
View File
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
it.effect("does not let server codes override a 4xx rejection", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
}),
)
+47 -3
View File
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
}
return json(route, {})
})
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
)
await page.addInitScript(
({ directory, server }) => {
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/status")
const response = await fetch("/api/info")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/status")
if (path === "/api/info")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+1 -1
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
+7 -1
View File
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { shouldHandlePasteAsAttachment } from "./interaction"
test("leaves paste to the browser when web clipboard data is unavailable", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
})
test("uses native image reading only when the clipboard has no text", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
})
test("handles clipboard files as attachments", () => {
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
})
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
return { types, items } as unknown as DataTransfer
}
@@ -384,20 +384,20 @@ export function createComposerEditor(input: {
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
@@ -408,13 +408,13 @@ export function createComposerEditor(input: {
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(text)
const node = document.createTextNode(normalized)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,6 +450,12 @@ export function createComposerEditor(input: {
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
+1
View File
@@ -15,6 +15,7 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
+10 -5
View File
@@ -90,7 +90,12 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
return Response.json({
version: "2.0.0-test",
pid: 1,
urls: [request.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -98,23 +103,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.status()
await transport.api.server.info()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.status()
await transport.api.server.info()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/status",
url: "http://127.0.0.1:4100/api/info",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/status",
url: "http://127.0.0.1:4200/api/info",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { createServerTransport } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
@@ -64,6 +65,44 @@ test("bootstraps projects through the native store setter and preserves subseque
}
})
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
test("recovers project metadata after the connection to the server is dropped", async () => {
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
let dropped = 0
const requests: string[] = []
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
if (dropped >= 2) return
dropped += 1
socket.terminate()
},
data(socket, chunk) {
requests.push(String(chunk).split(" ")[0] ?? "")
socket.end(
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`,
)
},
},
})
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
try {
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
loadProjectsQuery(ServerScope.local, transport.api.project),
)
expect(dropped).toBe(2)
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
} finally {
server.stop(true)
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
+29 -15
View File
@@ -17,7 +17,12 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: [server.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -28,16 +33,19 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/status")
expect(request?.pathname).toBe("/api/info")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -52,7 +60,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -111,10 +119,13 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -130,10 +141,13 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function checkServerHealth(
fetch,
headers,
})
.server.status({ signal })
.server.info({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
+4 -9
View File
@@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
// Retain closed paths until reopened so settings can exclude them from the server inventory.
// The Home page independently limits the visible recently closed entries.
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
@@ -51,6 +48,7 @@ export function createServerProjects(input: {
}
return {
list: current,
closed: currentClosed,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
@@ -72,10 +70,7 @@ export function createServerProjects(input: {
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/status").catch(() => undefined)
input.queue.fetch("http://server/api/info").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
],
},
},
+6 -1
View File
@@ -146,7 +146,11 @@ function createServerController(
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
const base = {
...metadata,
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
...project,
}
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
@@ -174,6 +178,7 @@ function createServerController(
projects: {
...projects,
list: projectsList,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
notification,
+14 -6
View File
@@ -6,12 +6,19 @@ import { useLanguage } from "@/runtime/i18n/language"
export function SettingsSearchEmpty(props: { query: string }) {
const language = useLanguage()
const [state, setState] = createStore({ query: props.query })
let quoted: HTMLSpanElement | undefined
let container: HTMLDivElement | undefined
let measure: HTMLSpanElement | undefined
const text = (query: string) => language.t("settings.search.empty.query", { query })
const update = () => {
if (!quoted || !measure) return
const width = quoted.getBoundingClientRect().width
if (!container || !measure) return
const style = getComputedStyle(container)
measure.textContent = language.t("settings.search.empty", { query: "" })
// Measure the available query space independently of its current truncated text.
const width =
container.clientWidth -
parseFloat(style.paddingInlineStart) -
parseFloat(style.paddingInlineEnd) -
measure.getBoundingClientRect().width
const fits = (query: string) => {
measure!.textContent = text(query)
return measure!.getBoundingClientRect().width <= width
@@ -40,21 +47,22 @@ export function SettingsSearchEmpty(props: { query: string }) {
createEffect(update)
onMount(() => {
if (!quoted || !measure) return
if (!container || !measure) return
// The measuring text also observes font changes that do not resize the available space.
createResizeObserver([quoted, measure], update)
createResizeObserver([container, measure], update)
})
return (
<>
<div
ref={container}
class="settings-search-empty"
role="status"
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
>
{language.rich("settings.search.empty", {
query: (
<span ref={quoted} class="settings-search-empty-quoted">
<span class="settings-search-empty-quoted">
<bdi dir="auto">{text(state.query)}</bdi>
</span>
),
+8 -6
View File
@@ -15,11 +15,12 @@
visibility: hidden;
}
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
.settings-search
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
@@ -37,19 +38,20 @@
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
@@ -7,14 +7,18 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: ServerCtx) {
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
]
}
+72
View File
@@ -803,6 +803,78 @@
padding-inline-end: 28px;
}
.settings-projects-search [data-component="text-input-v2"] {
height: 36px;
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
}
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
background: var(--v2-background-bg-layer-02);
}
.settings-projects-search [data-slot="text-input-v2-input"] {
padding-inline-end: 0;
}
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 16px;
}
.settings-projects-search [data-slot="text-input-v2-value"] {
gap: 12px;
}
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
margin-inline-end: -4px;
}
.settings-projects-empty {
width: 60%;
margin-inline: auto;
}
.settings-projects-empty .settings-search-empty {
justify-content: center;
padding-inline: 0;
}
.settings-projects-empty .settings-search-empty-quoted {
flex: 0 1 auto;
}
.settings-project-row {
position: relative;
margin-inline: 1px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: background-color 120ms;
}
.settings-project-row:hover {
background: var(--v2-background-bg-layer-02);
}
.settings-project-row-content {
display: flex;
width: 100%;
min-height: 73px;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
border-radius: inherit;
text-align: start;
}
.settings-project-row-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-tab-search-clear {
position: absolute;
top: 50%;
-3
View File
@@ -294,8 +294,6 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
autofocus={!surface.search.state.selected}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
@@ -0,0 +1,230 @@
import { Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { InlineInput } from "@opencode/ui/inline-input"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { showToast } from "@/shell/notifications/toast"
import type { LocalProject } from "@/shell/state/layout"
export function SettingsProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
onOpen: (project: LocalProject) => void
}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const [store, setStore] = createStore({
menu: undefined as { x: number; y: number } | undefined,
editor: undefined as { draft: string; saving: boolean } | undefined,
})
let row: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
let outside = false
const openMenu = (x: number, y: number) => {
if (!row) return
const bounds = row.getBoundingClientRect()
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
}
const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (store.editor?.saving) return
setStore("editor", undefined)
}
const saveEditor = async () => {
if (!store.editor || store.editor.saving) return
const name = store.editor.draft.trim()
if (!name || name === displayName(props.project)) {
closeEditor()
requestAnimationFrame(() => button?.focus())
return
}
setStore("editor", "saving", true)
const context = global.ensureServerCtx(props.server)
const value = name === getFilename(props.project.worktree) ? "" : name
const saved = await (props.project.id && props.project.id !== "global"
? context.sdk.api.project
.update({ projectID: props.project.id, name: value })
.then((project) => context.sync.project.update(project))
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
)
.then(() => true)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
const restore = document.activeElement === document.body || document.activeElement === input
if (saved) setStore("editor", undefined)
if (!saved) setStore("editor", "saving", false)
if (!restore) return
requestAnimationFrame(() => (saved ? button : input)?.focus())
}
return (
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => button?.focus())
}}
onBlur={closeEditor}
/>
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
>
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -1,45 +1,43 @@
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { SettingsSearchEmpty } from "../search-empty"
import { settingsProjects } from "../servers/inventory"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active?: boolean
autofocus?: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [store, setStore] = createStore({ filter: "" })
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
let search: HTMLInputElement | undefined
const updateOverflow = () => {
if (!search) return
const offset = Math.abs(search.scrollLeft)
setStore("overflow", {
start: offset > 1,
end: search.scrollWidth - search.clientWidth - offset > 1,
})
}
createEffect(on(() => store.filter, updateOverflow))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
createEffect(
on(
() => (props.active ?? true) && searchable(),
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
@@ -54,16 +52,24 @@ export const SettingsProjects: Component<{
</div>
</div>
<Show when={searchable()}>
<div class="settings-tab-search">
<div class="settings-tab-search settings-projects-search">
<TextInput
ref={search}
ref={(element) => {
search = element
createResizeObserver(element, updateOverflow)
}}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={store.filter}
data-overflow-start={store.overflow.start}
data-overflow-end={store.overflow.end}
onScroll={updateOverflow}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
clearIcon="circle-xmark"
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
@@ -81,32 +87,26 @@ export const SettingsProjects: Component<{
<Show
when={filtered().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
}
>
<div class="flex w-full flex-col gap-2">
<For each={filtered()}>
<Key each={filtered()} by="worktree">
{(project) => (
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
)}
</For>
</Key>
</div>
</Show>
</div>
+9 -9
View File
@@ -2,7 +2,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -42,12 +42,12 @@ try {
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const status = await waitForReady(info.url, headers)
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
const serverInfo = await waitForReady(info.url, headers)
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -57,10 +57,10 @@ try {
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -128,11 +128,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const response = await fetch(new URL("/api/status", url), {
const response = await fetch(new URL("/api/info", url), {
headers,
signal: AbortSignal.timeout(1_000),
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
+1 -1
View File
@@ -15,7 +15,7 @@ export default Runtime.handler(
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
+44 -9
View File
@@ -20,7 +20,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const empty: Info = {}
export const layer = Layer.effect(
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
: undefined
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
return parseRecord(text)
})
const write = Effect.fnUntraced(function* (text: string) {
@@ -61,6 +61,9 @@ export const layer = Layer.effect(
}),
),
)
const load = Effect.fnUntraced(function* (migration?: Info) {
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
@@ -72,8 +75,7 @@ export const layer = Layer.effect(
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
return yield* load(migration?.info)
}),
),
)
@@ -83,7 +85,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const current = yield* load(migration?.info)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
@@ -102,7 +104,7 @@ export const layer = Layer.effect(
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
return merge(config, content)
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
@@ -113,6 +115,39 @@ export const layer = Layer.effect(
type Edit = { readonly path: (string | number)[]; readonly value: any }
function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
() => empty,
)
}
function mergeRecords(base: object, overlay: object) {
return Object.entries(overlay).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
return result
},
{ ...base },
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseRecord(text: string) {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
}
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
@@ -29,7 +29,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== OPENCODE_VERSION)
+2 -2
View File
@@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
@@ -280,7 +280,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
}
function status() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
}
function located<T>(data: T) {
+45
View File
@@ -71,6 +71,51 @@ test("preserves the schema in an existing cli.json", async () => {
expect(await Bun.file(file).json()).toEqual(config)
})
test("merges inline CLI config content over the global config", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
await Bun.write(
file,
JSON.stringify({
tabs: { enabled: true, scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
}),
)
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
tabs: { enabled: false },
keybinds: { "help.show": false },
plugins: ["inline"],
animations: false,
})
try {
const result = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
const loaded = yield* service.get()
const updated = yield* service.update((draft) => {
draft.animations = true
draft.mouse = false
})
return { loaded, updated }
}),
)
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
} finally {
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
}
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+7 -2
View File
@@ -39,9 +39,14 @@ describe("debug config command", () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") {
if (url.pathname === "/api/info") {
healthProbes += 1
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
}
requested = url
authorization.push(request.headers.get("authorization"))
+1
View File
@@ -4,6 +4,7 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
return {
...process.env,
HOME: root,
OPENCODE_CLI_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
@@ -12,7 +12,7 @@ await Effect.runPromise(
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
+7 -6
View File
@@ -42,7 +42,8 @@ const sanitizedTransfer = {
],
}
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
const status = () =>
Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -60,7 +61,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/experimental/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
@@ -98,7 +99,7 @@ test("export requires a session outside an interactive terminal", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
@@ -127,7 +128,7 @@ test("export reports a missing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/experimental/session/${sessionID}/export`) {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
@@ -158,7 +159,7 @@ test("import validates a file and sends it to the resolved location", async () =
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
@@ -201,7 +202,7 @@ test("import reports an existing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
+26 -6
View File
@@ -31,14 +31,24 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const controller = new AbortController()
@@ -57,7 +67,7 @@ describe("mini command", () => {
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.server.status()
await client.server.info()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
@@ -147,8 +157,13 @@ describe("mini command", () => {
async fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/status")
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
if (url.pathname === "/api/info")
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/session") {
@@ -190,7 +205,12 @@ describe("mini command", () => {
port: 0,
fetch(request) {
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
return Response.json({
version: "incompatible",
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
+8 -4
View File
@@ -256,13 +256,14 @@ test("concurrent service processes elect one server", async () => {
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/status", info.url), {
await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toEqual({
version: info.version,
pid: info.pid,
urls: [info.url],
paths: { tmp: path.join(os.tmpdir(), "opencode") },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
@@ -337,7 +338,7 @@ test.each([
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/status", info.url), {
const response = await fetch(new URL("/api/info", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
@@ -440,7 +441,10 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
return Response.json(
{ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -575,7 +579,7 @@ async function waitForInfo(file: string, accept: (info: Info) => boolean = () =>
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/status", info.url), {
const status = await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
+14 -4
View File
@@ -35,8 +35,13 @@ describe("web UI", () => {
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/status")
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
if (pathname === "/api/info")
return HttpServerResponse.jsonUnsafe({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
@@ -47,8 +52,13 @@ describe("web UI", () => {
)
const origin = HttpServer.formatAddress(http.address)
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
const status = yield* Effect.promise(() => fetch(`${origin}/api/info`))
expect(yield* Effect.promise(() => status.json())).toEqual({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
+8 -3
View File
@@ -39,23 +39,28 @@ import type { Vcs } from "@opencode/schema/vcs"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
export type ServerStatusOutput = {
export type ServerInfoOutput = {
readonly version: string
readonly pid: number
readonly urls: ReadonlyArray<string>
readonly paths: { readonly tmp: string }
}
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export interface ServerApi<E = never> {
readonly status: ServerStatusOperation<E>
readonly info: ServerInfoOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type LocationGetOutput = Location.PublicInfo
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
export type LocationReloadOutput = void
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly reload: LocationReloadOperation<E>
}
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+12 -5
View File
@@ -5,9 +5,10 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -277,17 +278,23 @@ const preserveStream =
<E, R>(stream: Stream.Stream<A, E, R>) =>
stream
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
get: EndpointLocationGet(raw),
reload: EndpointLocationReload(raw),
})
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
preserveEffect<AgentListOutput>()(
+8 -8
View File
@@ -172,7 +172,7 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeStatus = Schema.decodeUnknownOption(
const decodeInfo = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -213,7 +213,7 @@ const probeResult = Effect.fnUntraced(function* (
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -225,7 +225,7 @@ const probeResult = Effect.fnUntraced(function* (
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -239,16 +239,16 @@ const probeResult = Effect.fnUntraced(function* (
timedOut: false,
}
const body = result.value.body
const status = decodeStatus(body)
if (Option.isSome(status)) {
if (status.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.value.version !== info.version)
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: status.value.version,
version: serverInfo.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -1,7 +1,8 @@
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -397,9 +398,9 @@ export function make(options: ClientOptions) {
return {
server: {
status: (requestOptions?: RequestOptions) =>
request<ServerStatusOutput>(
{ method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
info: (requestOptions?: RequestOptions) =>
request<ServerInfoOutput>(
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -416,6 +417,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
reload: (requestOptions?: RequestOptions) =>
request<LocationReloadOutput>(
{
method: "POST",
path: `/api/location/reload`,
successStatus: 204,
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
+22 -10
View File
@@ -1,6 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
@@ -820,6 +820,15 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type LocationShutdown = {
id: string
created: number
metadata?: { [x: string]: any }
type: "location.shutdown"
location?: LocationRef
data: {}
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2321,6 +2330,7 @@ export type IntegrationInfo = {
}
export type V2Event =
| LocationShutdown
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
@@ -2429,14 +2439,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
@@ -2445,6 +2447,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2645,7 +2655,7 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type ServerStatusOutput = ServerStatus
export type ServerInfoOutput = ServerInfo
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
@@ -2653,6 +2663,8 @@ export type LocationGetInput = {
export type LocationGetOutput = LocationPublicInfo
export type LocationReloadOutput = void
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+9 -8
View File
@@ -163,7 +163,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -174,7 +174,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -187,15 +187,16 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
} satisfies LocalService,
timedOut: false,
}
const status = decodeStatus(result.value.body)
if (status !== undefined) {
if (status.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.version !== info.version) return { service: undefined, 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: status.version,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -205,7 +206,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
return { service: undefined, timedOut: false }
}
function decodeStatus(input: unknown) {
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
+6
View File
@@ -592,6 +592,12 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "location.shutdown": {
if (!event.location) return
result.location.invalidate(event.location)
refresh(() => result.location.sync(event.location))
return
}
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
+14 -4
View File
@@ -15,21 +15,31 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("server.status decodes the readiness response", async () => {
test("server.info decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
Response.json({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
}),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.server.status()
return yield* client.server.info()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
expect(result).toEqual({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
})
test("vcs.base decodes nullable review-base metadata", async () => {
+15 -4
View File
@@ -52,7 +52,7 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname !== "/api/status") return new Response(null, { status: 404 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
@@ -65,10 +65,21 @@ const server = Bun.serve({
return new Response(null, { status: 503 })
}
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 503 })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
if (mode === "failed-owner")
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 500 })
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 500 },
)
return Response.json({
version,
pid: process.pid,
urls: [server.url.toString()],
paths: { tmp: "/tmp/opencode" },
})
},
})
+24 -8
View File
@@ -192,19 +192,29 @@ test("websearch.query uses the public HTTP contract", async () => {
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.status uses the public HTTP contract", async () => {
test("server.info uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
},
})
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
expect(await client.server.info()).toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/status")
expect(request?.url).toBe("http://localhost:3000/api/info")
})
test("experimental wellknown integration add uses the public HTTP contract", async () => {
@@ -686,7 +696,7 @@ test("event.subscribe reports heartbeat comments as stream activity", async () =
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary status requests", async () => {
test("event transport passes through ordinary info requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
@@ -699,16 +709,22 @@ test("event transport passes through ordinary status requests", async () => {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.server.status()).resolves.toEqual({
await expect(client.server.info()).resolves.toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
expect(requests).toEqual(["/api/event", "/api/status"])
expect(requests).toEqual(["/api/event", "/api/info"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
+1 -1
View File
@@ -300,5 +300,5 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
}
async function status(url: string) {
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
return fetch(new URL("/api/info", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+6 -14
View File
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
@@ -94,19 +94,11 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Extension.make`
### `Values`
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
```ts
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
extension throws at `CodeMode.make`.
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
+18 -19
View File
@@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array 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
shadowable by program declarations like other globals.
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
synchronous iterators, and synchronous generators.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
@@ -179,10 +179,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -450,13 +451,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
## Extensions
@@ -466,8 +461,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
@@ -478,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
left behind rather than replacing the error.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
-2
View File
@@ -17,7 +17,6 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -70,7 +69,6 @@ const walk = <R>(
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
stack.add(target)
+12 -4
View File
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
@@ -146,9 +146,17 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const prototype = builtins[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
return Effect.map(created, (value) => {
define(value, "cause", get(options, "cause"), hidden)
return value
})
}
const ctor: Native<R> = constructor<R>(builtins, prototype, {
name: type,
@@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { Throw, typeError } from "./model.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
keys,
Arr,
Bytes,
DateObj,
@@ -24,7 +27,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { describeValue } from "./references.js"
@@ -50,7 +52,6 @@ export const extensionGlobals = <R>(
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
@@ -62,14 +63,28 @@ export const extensionGlobals = <R>(
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
const copied =
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
for (const key of new Set(["cause", ...keys(value)])) {
if (uncrossed.has(key) || !has(value, key)) continue
const item = crossing(() => next(get(value, key)))
if (item === left) continue
Object.defineProperty(copied, key, {
value: item,
writable: true,
configurable: true,
enumerable: key !== "cause",
})
}
seen.delete(value)
return copied
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const copied =
value instanceof Arr
? value.items.map(next)
@@ -87,19 +102,29 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
if (value instanceof Error) {
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
const fields = value as unknown as Record<string, unknown>
for (const key of new Set(["cause", ...Object.keys(value)])) {
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
const item = crossing(() => next(fields[key], `${label}.${key}`))
if (item === left) continue
define(copied, key, item, key === "cause" ? hidden : undefined)
}
seen.delete(value)
return copied
}
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -163,6 +188,22 @@ export const extensionGlobals = <R>(
)
}
/**
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = (convert: () => unknown): unknown => {
try {
return convert()
} catch (reason) {
if (reason instanceof PendingThrow) return left
throw reason
}
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
@@ -11,7 +11,6 @@ import { objectGlobal } from "../stdlib/object.js"
import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -81,7 +80,6 @@ const table: Record<string, Factory> = {
Set: (ctx) => setGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
TextEncoder: (ctx) => textEncoderGlobal(ctx),
TextDecoder: (ctx) => textDecoderGlobal(ctx),
@@ -84,7 +84,6 @@ import {
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -654,7 +653,7 @@ class Frame<R> {
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
}
@@ -757,11 +756,9 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
@@ -1851,7 +1848,6 @@ class Frame<R> {
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
@@ -29,7 +29,6 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -81,7 +80,6 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+1 -11
View File
@@ -156,15 +156,6 @@ export class URLSearchParamsObj extends Obj {
}
}
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
}
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
@@ -190,14 +181,13 @@ export class Bytes extends Obj {
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -16,7 +16,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
@@ -86,7 +85,6 @@ export const describeValue = (value: unknown): string => {
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (isRuntimeReference(value)) return "a function"
-2
View File
@@ -12,7 +12,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -67,7 +66,6 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
-119
View File
@@ -1,119 +0,0 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
const attempt = <T>(run: () => T): T => {
try {
return run()
} catch (error) {
throw typeError(error instanceof Error ? error.message : String(error))
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
return Effect.gen(function* () {
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
if (!(init instanceof Obj) || isRuntimeReference(init)) {
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
}
return wrap(
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
)
})
}
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Headers
const headers = constructor<R>(builtins, proto, {
name: "Headers",
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
const target = self(thisValue, "append").headers
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
const target = self(thisValue, "delete").headers
return attempt(() => target.delete(arg(args, 0)))
},
],
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
const target = self(thisValue, "get").headers
return attempt(() => target.get(arg(args, 0)))
},
],
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
const target = self(thisValue, "has").headers
return attempt(() => target.has(arg(args, 0)))
},
],
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
const target = self(thisValue, "set").headers
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
return undefined
})
},
],
])
return headers
}
+18 -27
View File
@@ -107,10 +107,12 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -124,29 +126,6 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect
}
})
/**
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
* WebIDL, the whole sequence is converted before any pair's length is checked.
*/
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor === undefined) return undefined
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
return pairs as Array<[string, string]>
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
}
})
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
@@ -160,8 +139,20 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
const cursor = yield* ctx.iterate(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
}
}
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
-2
View File
@@ -13,7 +13,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -29,7 +28,6 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
+58 -29
View File
@@ -128,34 +128,6 @@ describe("values are converted at the boundary, never shared", () => {
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("Headers cross as copies in both directions", async () => {
const stored = new Headers({ "X-A": "1" })
const target = CodeMode.make({
extensions: [
Extension.make({
name: "http",
globals: {
headers: () => stored,
keep: (value: Headers) => {
held.push(value)
return value
},
},
}),
],
})
held.length = 0
expect(
await value(
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
target,
),
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
expect(stored.get("x-a")).toBe("1")
expect(held[0]).toBeInstanceOf(Headers)
expect((held[0] as Headers).get("x-a")).toBe("2")
})
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
const stored = new Uint8Array([1, 2, 3])
const target = CodeMode.make({
@@ -191,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[0]).toEqual({ a: 1 })
})
test("a program Error crosses as a host Error with its name and message", async () => {
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
held.length = 0
await value(`keep(new TypeError("bad"))`)
expect(held[0]).toBeInstanceOf(TypeError)
expect((held[0] as Error).message).toBe("bad")
expect(Object.keys(held[0] as object)).toEqual([])
held.length = 0
await value(`
const e = new Error("m", { cause: new RangeError("root") })
e.code = "ENOENT"; e.detail = { path: "x" }
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
keep(e)`)
const crossed = held[0] as Error & Record<string, unknown>
expect(crossed.cause).toBeInstanceOf(RangeError)
expect((crossed.cause as Error).message).toBe("root")
expect(Object.keys(crossed)).toEqual(["code", "detail"])
expect(crossed.detail).toEqual({ path: "x" })
expect(crossed.stack).not.toBe("chosen")
expect(String(crossed)).toBe("Error: m")
expect(crossed.constructor).toBe(Error)
})
test("an Error with an unknown name crosses as a plain Error", async () => {
@@ -259,6 +245,49 @@ describe("host errors", () => {
])
})
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
class Handle {}
const target = CodeMode.make({
extensions: [
Extension.make({
name: "fs",
globals: {
open: () => {
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
code: "ENOENT",
errno: -2,
path: "x",
detail: { retried: true },
handle: new Handle(),
retry: () => 1,
})
throw new Error("open failed", { cause: error })
},
},
}),
],
})
expect(
await value(
`try { open() } catch (e) {
const c = e.cause
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
}`,
target,
),
).toEqual([
"open failed",
[],
true,
"ENOENT",
-2,
"x",
{ retried: true },
["code", "errno", "path", "detail"],
false,
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
+8 -8
View File
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.status")).not.toBeUndefined()
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
@@ -978,10 +978,10 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const status = toolAt(result.tools, "server.status")
const statusInput = Tool.isTool(status) && isRecord(status.input) ? status.input : undefined
expect(statusInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(statusInput) ? statusInput : {}
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
return search({ query: "server status", namespace: "opencode", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -1005,8 +1005,8 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.server.status",
description: "Return the server identity, connection URLs, and readiness status.",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
+10
View File
@@ -350,6 +350,16 @@ describe("Error values and instanceof", () => {
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
expect(
await value(`
const inner = new Error("root")
const e = new TypeError("m", { cause: inner })
const agg = new AggregateError([], "a", { cause: 3 })
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
).toEqual([true, [], false, true, 3])
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
-148
View File
@@ -635,154 +635,6 @@ describe("URL and URI helpers", () => {
})
})
describe("Headers", () => {
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
expect(
await value(`
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
headers.append("Accept", "text/html")
headers.append("accept", "application/json")
headers.set("x-count", "2")
headers.delete("x-null")
const copy = new Headers(headers)
copy.set("content-type", "text/html")
return {
get: headers.get("content-type"),
missing: headers.get("x-missing"),
combined: headers.get("ACCEPT"),
has: [headers.has("Accept"), headers.has("x-null")],
count: headers.get("x-count"),
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: headers.keys(),
values: headers.values(),
entries: headers.entries(),
}
`),
).toEqual({
get: "text/plain",
missing: null,
combined: "text/html, application/json",
has: [true, false],
count: "2",
copied: ["text/plain", "text/html"],
pairs: [
["a", "1"],
["b", "2"],
],
map: [["k", "v"]],
keys: ["accept", "content-type", "x-count"],
values: ["text/html, application/json", "text/plain", "2"],
entries: [
["accept", "text/html, application/json"],
["content-type", "text/plain"],
["x-count", "2"],
],
})
})
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
expect(
await value(`
const headers = new Headers({ b: "2", a: "1" })
headers.append("Set-Cookie", "x=1")
headers.append("set-cookie", "y=2")
const seen = []
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
const [first] = headers
function* pairs() { yield* headers }
return {
seen,
first,
spread: [...headers],
from: Array.from(headers).length,
generator: [...pairs()].length,
object: Object.fromEntries(headers),
cookies: headers.getSetCookie(),
}
`),
).toEqual({
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
first: ["a", "1"],
spread: [
["a", "1"],
["b", "2"],
["set-cookie", "x=1"],
["set-cookie", "y=2"],
],
from: 4,
generator: 4,
object: { a: "1", b: "2", "set-cookie": "y=2" },
cookies: ["x=1", "y=2"],
})
})
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
const result = await run(`
const headers = new Headers({ "X-A": "1", b: "2" })
console.log(headers)
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
`)
expect(result.ok && result.value).toEqual({
headers: { b: "2", "x-a": "1" },
json: '{"headers":{"b":"2","x-a":"1"}}',
text: "[object Headers]",
type: "object",
is: true,
})
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
})
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
expect(
await value(`
function message(run) {
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
}
const headers = new Headers()
return [
message(() => Headers()),
message(() => new Headers(null)),
message(() => new Headers(1)),
message(() => new Headers("a=1")),
message(() => new Headers(new Date())),
message(() => new Headers(() => 1)),
message(() => new Headers([["name"]])),
message(() => new Headers([["a", "b", "c"]])),
message(() => new Headers({ "bad name": "x" })),
message(() => new Headers({ name: "bad\u0000value" })),
message(() => headers.get("invalid\u0100")),
message(() => headers.has({})),
message(() => headers.set("a", "invalid\u0100")),
message(() => headers.append("a")),
message(() => headers.forEach()),
message(() => headers.forEach(1)),
message(() => { const get = headers.get; return get("a") }),
]
`),
).toEqual([
"Constructor Headers requires 'new'.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects iterable [name, value] pairs.",
"new Headers(...) expects iterable [name, value] pairs.",
expect.stringContaining("bad name"),
expect.stringContaining("invalid value"),
expect.stringContaining("Invalid header name"),
expect.stringContaining("[object Object]"),
expect.stringContaining("invalid value"),
"Headers.append requires 2 arguments.",
"Headers.forEach requires 1 argument.",
"Headers.forEach expects a function callback.",
"Headers.prototype.get called on incompatible receiver undefined.",
])
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
-225
View File
@@ -3,13 +3,10 @@
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
* - WebCryptoAPI/randomUUID.https.any.js
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -169,225 +166,3 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
).toEqual([true, true, true, 768])
})
})
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
const testharness = `
const failures = []
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
function assert_true(actual, message) { assert_equals(actual, true, message) }
function assert_false(actual, message) { assert_equals(actual, false, message) }
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
function assert_unreached() { throw new Error("unreachable") }
`
describe("Headers WPT parity (fetch/api/headers)", () => {
test("headers-basic.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { new Headers() }, "Create headers from no parameter")
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
test(function() { new Headers({}) }, "Create headers from empty object")
var parameters = [null, 1]
parameters.forEach(function(parameter) {
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
})
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
var headerSeq = []
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
test(function() {
var headers = new Headers(headerSeq)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
}, "Create headers with sequence")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with record")
test(function() {
var headers = new Headers(headerDict)
var headers2 = new Headers(headers)
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with existing headers")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.append(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check append method")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.set(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check set method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
}, "Check has method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) {
assert_true(headers.has(name), "headers have a header: " + name)
headers.delete(name)
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
}
}, "Check delete method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
}, "Check get method")
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
var sortedHeaderDict = {}
var headerValues = []
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
headerValues.push(headerEntriesDict[value])
return value.toLowerCase()
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var index = 0
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
})
assert_equals(index, sortedHeaderKeys.length)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
headers.delete("foo")
}
assert_array_equals(actualKeys, ["bar", "baz"])
assert_array_equals(actualValues, ["0", "1"])
}, "Iteration skips elements removed while iterating")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.delete("bar")
}
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
assert_array_equals(actualValues, ["0", "1", "3"])
}, "Removing elements already iterated over causes an element to be skipped during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("X-yZ", "4")
}
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
}, "Appending a value pair during iteration causes it to be reached during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("abc", "-1")
}
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
return failures
`),
).toEqual([])
})
test("headers-errors.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
var badNames = ["invalid\u0100", {}]
var badValues = ["invalid\u0100"]
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
})
test(function() {
var headers = new Headers([["name", "value"]])
assert_throws_js(TypeError, function() { headers.forEach() })
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
assert_throws_js(TypeError, function() { headers.forEach(1) })
}, "Headers forEach throws if argument is not callable")
test(function() {
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
var counter = 0
try {
headers.forEach(function(value, name) {
counter++
if (name == "name2") throw "error"
})
} catch (e) {
assert_equals(counter, 2)
assert_equals(e, "error")
return
}
assert_unreached()
}, "Headers forEach loop should stop if callback is throwing exception")
return failures
`),
).toEqual([])
})
})
+14 -9
View File
@@ -79,6 +79,7 @@ export interface ListInput {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
@@ -100,6 +101,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
let closed = false
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
@@ -137,6 +139,7 @@ export const layer = Layer.effect(
}
yield* Cache.set(forms, id, entry)
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
if (closed) yield* cancel(id).pipe(Effect.orDie)
return form
}),
),
@@ -202,19 +205,21 @@ export const layer = Layer.effect(
),
)
yield* Effect.addFinalizer(() =>
Cache.values(forms).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
const close = Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
)
yield* Effect.addFinalizer(() => close)
return Service.of({ create, ask, get, list, state, reply, cancel })
return Service.of({ create, ask, get, list, state, reply, cancel, close })
}),
)
+4 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Layer } from "effect"
import { Context, Effect, Layer } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Model } from "./model.js"
@@ -18,6 +18,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
@@ -57,6 +58,7 @@ export { Service, node, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
LocationLifecycle.node,
Environment.node,
Config.node,
Agent.node,
@@ -157,6 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
+52
View File
@@ -0,0 +1,52 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Rpc } from "./rpc.js"
export class Service extends Context.Service<
Service,
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
>()("@opencode/LocationLifecycle") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
closed = true
yield* permission.close
yield* forms.close
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible),
)
return Service.of({
isClosed: () => closed,
shutdown,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
})
+19 -1
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Node } from "@opencode/util/effect/app-node"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -17,6 +17,24 @@ export class Service extends Context.Service<
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
const locations = yield* Service
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
discard: true,
concurrency: "unbounded",
})
// Boot every replacement now and let all builds settle even if one fails.
const results = yield* Effect.forEach(
refs,
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
{ concurrency: "unbounded" },
)
const failure = results.find(Exit.isFailure)
if (failure) return yield* Effect.failCause(failure.cause)
yield* Effect.logInfo("location services reloaded", { count: refs.length })
})
/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
+13 -7
View File
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Rpc } from "./rpc.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -28,12 +28,17 @@ export function buildLocationServiceMap(
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
if (Exit.isSuccess(exit)) {
return Effect.gen(function* () {
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
// A boot detached while in flight still needs shutdown and cancellation.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
return yield* lifecycle.shutdown
build.close = lifecycle.shutdown
})
}
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
if (Exit.isSuccess(exit)) {
build.close = Context.get(exit.value, Rpc.Service).close
return Effect.void
}
MutableHashMap.remove(builds, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
@@ -61,10 +66,11 @@ export function buildLocationServiceMap(
const key = LocationServiceMap.canonical(ref)
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
MutableHashMap.remove(builds, key)
// Detach routing first, then end pending RPCs that still borrow the old graph.
// Detach routing first, then cancel interactions and notify clients. Running
// steps retain their borrowed graph until they can hand off at a boundary.
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
}),
}).pipe(Effect.uninterruptible),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+23 -12
View File
@@ -101,6 +101,7 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -127,18 +128,22 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const close = Effect.gen(function* () {
closed = true
yield* Effect.forEach(Array.from(pending.values()), (item) =>
bus
.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
)
pending.clear()
}).pipe(Effect.uninterruptible)
yield* Effect.addFinalizer(() => close)
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
@@ -201,6 +206,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (closed) {
yield* Deferred.fail(deferred, new DeclinedError())
return item
}
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
@@ -212,6 +221,7 @@ const layer = Layer.effect(
)
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
const result = yield* evaluateInput(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
@@ -220,6 +230,7 @@ const layer = Layer.effect(
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
if (closed) return yield* Effect.die(new DeclinedError())
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -321,7 +332,7 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list, close })
}),
)
+13 -9
View File
@@ -42,13 +42,17 @@ that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
inline JSON over the global settings. Most preferences can also be changed from
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
and terminal integration. Do not put these settings in `opencode.json(c)`.
### [Settings](https://opencode.ai/v2/docs/cli/config)
Fetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It documents every terminal-only setting, accepted
values, and examples, including themes, input, sessions, tabs, diffs, alerts,
Mini, keybindings, terminal plugins, and debugging. Do not put these settings
in `opencode.json(c)`.
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
@@ -92,7 +96,7 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
`references`, `formatter`, and `lsp`.
This configuration is distinct from `cli.json`. Use the
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal
preferences, especially themes and keybindings.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
@@ -190,7 +194,7 @@ HTTP method and path or an OpenAPI operation ID.
Call an endpoint with an HTTP method and path:
```sh
opencode api get /api/status
opencode api get /api/info
```
Pass a request body with `--data` or `-d`, and additional headers with
@@ -241,7 +245,7 @@ OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode service status` and verify the API with
`opencode api get /api/status`.
`opencode api get /api/info`.
- Compare with `opencode --standalone`, which runs the TUI with a private
server, to isolate shared-service issues.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
+3
View File
@@ -57,6 +57,9 @@ export const Plugin = define({
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
if (session.parentID) return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+1
View File
@@ -108,6 +108,7 @@ export const layer = Layer.effect(
return yield* SessionRunner.DrainResult.$match(result, {
Complete: () => Effect.void,
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
})
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -100,7 +100,6 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -124,7 +123,9 @@ export const layer = (options?: Options) =>
state,
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -208,7 +209,7 @@ export const layer = (options?: Options) =>
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery, suspended)
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+18 -9
View File
@@ -320,15 +320,24 @@ export const layer = Layer.effect(
// which transport actually carries the request, so both hook families are always offered.
const webSocket =
input.webSocket === "session" && model.transport === "websocket"
? transport.bind(session.id, (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
)
? transport.bind(session.id, {
handshake: (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
send: (frame) =>
hooks
.trigger("session", "experimental.ws.send", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
receive: (frame) =>
hooks
.trigger("session", "experimental.ws.receive", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
})
: undefined
return {
+21 -13
View File
@@ -59,11 +59,18 @@ export interface Handshake {
readonly headers: Record<string, string>
}
/**
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
*/
export interface Interceptor {
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
readonly send?: (frame: string) => Effect.Effect<string>
readonly receive?: (frame: string) => Effect.Effect<string>
}
export interface Interface {
readonly bind: (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
) => WebSocketChannelExecutor
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
@@ -278,7 +285,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
input: WebSocketChannelExchange,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
interceptor?: Interceptor,
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -288,8 +295,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "not-sent",
})
if (owner.httpFallback) return fallback(input)
const selected = handshake
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
const selected = interceptor?.handshake
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
: undefined
const exchange: WebSocketChannelExchange = selected
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
@@ -354,6 +361,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const message = interceptor?.send
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
: create.message
yield* Effect.logDebug("session websocket sending", {
sessionTransport: "websocket",
phase: "send",
@@ -364,7 +374,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "send-attempted",
}
channel.active = active
const sent = yield* channel.connection.sendText(create.message).pipe(
const sent = yield* channel.connection.sendText(message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
@@ -405,6 +415,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
}),
),
}),
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
@@ -482,10 +493,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
return { frames, complete, http: channel.connection.http }
})
const bind = (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
): WebSocketChannelExecutor => ({
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
let execution: WebSocketChannelExecution | undefined
@@ -495,7 +503,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, handshake)),
Effect.andThen(start(owner, exchange, interceptor)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
@@ -22,6 +22,7 @@ export type Continuation = { readonly step: number }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
+8
View File
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { LocationLifecycle } from "../../location-lifecycle.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -37,6 +38,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const lifecycle = yield* LocationLifecycle.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelTransport = yield* SessionModelTransport.Service
@@ -69,6 +71,10 @@ const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
if (lifecycle.isClosed()) {
yield* restore(modelTransport.close(sessionID))
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
}
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
@@ -240,6 +246,7 @@ const layer = Layer.effect(
webSocket: "session",
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
sessionID,
assistantMessageID,
agent: loaded.agent.id,
@@ -356,6 +363,7 @@ export const node = makeLocationNode({
layer,
deps: [
Bus.node,
LocationLifecycle.node,
llmClient,
SessionContext.node,
SessionModelTransport.node,
+8 -3
View File
@@ -42,6 +42,7 @@ export type Outcome = Data.TaggedEnum<{
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly isLocationClosed: () => boolean
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
@@ -190,8 +191,9 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
message: input.isLocationClosed()
? "Interaction cancelled because the location shut down"
: decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
@@ -251,7 +253,10 @@ export const make = Effect.gen(function* () {
return Outcome.Continue({ error: llmError, decision: retry })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.declines.length > 0) {
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
return yield* Effect.interrupt
}
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
+2 -2
View File
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const data = {
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
trace: { region: "test-region" },
}
const cause = apiCallError({ statusCode: 400, data })
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason.http?.status).toBe(400)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(error.reason.body).toBe(JSON.stringify(data))
+2 -2
View File
@@ -1,5 +1,5 @@
import { Permission } from "@opencode/core/permission"
import { Layer } from "effect"
import { Effect, Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, overrides)
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
+1
View File
@@ -55,6 +55,7 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
close: Effect.void,
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
+16 -3
View File
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
})
describe("SessionRestart background recovery", () => {
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
@@ -416,6 +416,20 @@ describe("SessionRestart background recovery", () => {
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
yield* seedInbox(database, parent, ["steer"])
yield* seedInbox(database, child, ["steer"])
yield* execution.wake(parent)
yield* execution.wake(child)
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([parent, child].toSorted())
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
{
@@ -509,7 +523,7 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([sessionID])
expect(drained).toEqual([])
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
@@ -561,7 +575,6 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
@@ -80,7 +80,7 @@ describe("SessionModelRequest HTTP hooks", () => {
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the handshake hook", () =>
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
@@ -92,13 +92,31 @@ describe("SessionModelRequest HTTP hooks", () => {
delete event.headers["api-key"]
}),
)
yield* hooks.register("session", "experimental.ws.send", (event) =>
Effect.sync(() => {
seen.push(`send:${event.kind}:${event.frame}`)
event.frame = `${event.frame}+plugin`
}),
)
yield* hooks.register("session", "experimental.ws.receive", (event) =>
Effect.sync(() => {
seen.push(`receive:${event.kind}:${event.frame}`)
event.frame = event.frame.toUpperCase()
}),
)
const bound: Array<{ url: string; headers: Record<string, string> }> = []
const frames: string[] = []
const websocketTransport = SessionModelTransport.Service.of({
bind: (_sessionID, handshake) => ({
bind: (_sessionID, interceptor) => ({
execute: () =>
Effect.gen(function* () {
if (!handshake) throw new Error("Expected a handshake interceptor")
bound.push(yield* handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }))
if (!interceptor?.handshake || !interceptor.send || !interceptor.receive)
throw new Error("Expected a full WebSocket interceptor")
bound.push(
yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }),
)
frames.push(yield* interceptor.send("create"))
frames.push(yield* interceptor.receive("created"))
return { frames: Stream.empty, complete: Effect.void }
}),
}),
@@ -127,7 +145,12 @@ describe("SessionModelRequest HTTP hooks", () => {
expect(prepared.options.webSocket).toBeDefined()
yield* prepared.options.webSocket!.execute({} as never)
expect(bound).toEqual([{ url: "wss://example.test/v1/responses", headers: { authorization: "Bearer minted" } }])
expect(seen).toEqual(["handshake:primary:wss://example.test/v1/responses"])
expect(frames).toEqual(["create+plugin", "CREATED"])
expect(seen).toEqual([
"handshake:primary:wss://example.test/v1/responses",
"send:primary:create",
"receive:primary:created",
])
}),
)
})
@@ -178,12 +178,13 @@ describe("SessionModelTransport", () => {
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
)
const executor = transport.bind(session, {
handshake: (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
})
yield* collect(executor, exchange("first", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("second", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("third", { headers: { "api-key": "k" } }))
@@ -196,6 +197,36 @@ describe("SessionModelTransport", () => {
)
})
test("sends the frame the send tap returns and observes the frame the receive tap returns", async () => {
const fixture = automatic()
const seen: Array<{ tap: "send" | "receive"; frame: string }> = []
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, {
send: (frame) => {
seen.push({ tap: "send", frame })
return Effect.succeed(`${frame}:rewritten`)
},
receive: (frame) => {
seen.push({ tap: "receive", frame })
return Effect.succeed(`${frame}:observed`)
},
})
const frames = yield* collect(executor, exchange("first"))
// The wire carries the rewritten outbound frame; the driver sees the rewritten inbound frame.
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:rewritten"]])
expect(frames).toEqual(["completed:first:rewritten:observed"])
expect(seen).toEqual([
{ tap: "send", frame: "first" },
{ tap: "receive", frame: "completed:first:rewritten" },
])
}),
)
})
test("does not carry a checkpoint across physical connection rotation", async () => {
const fixture = automatic()
const checkpoints: Array<unknown> = []
+1
View File
@@ -100,6 +100,7 @@ for (const fixture of [
)
const result = yield* steps
.attempt({
isLocationClosed: () => false,
sessionID,
assistantMessageID,
agent: Agent.defaultID,
@@ -15,6 +15,31 @@ test.each(["build", "serve"] as const)("configures minification for %s", async (
expect(result?.config.renderer?.build?.sourcemap).toBe(true)
})
test("onboarding preview is enabled only by the development test flag", async () => {
const previous = process.env.OPENCODE_TEST_ONBOARDING
try {
process.env.OPENCODE_TEST_ONBOARDING = "1"
for (const command of ["build", "serve"] as const) {
const result = await loadConfigFromFile(
{ command, mode: command === "build" ? "production" : "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe(
JSON.stringify(command === "serve"),
)
}
delete process.env.OPENCODE_TEST_ONBOARDING
const result = await loadConfigFromFile(
{ command: "serve", mode: "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false")
} finally {
delete process.env.OPENCODE_TEST_ONBOARDING
if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous
}
})
test("does not package external copies of bundled dependencies", () => {
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
expect(Object.keys(pkg.dependencies)).not.toContain(name)
+3
View File
@@ -96,6 +96,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
),
},
plugins: [pickerPlugin(), appPlugin, sentry],
publicDir: "../../../app/public",
@@ -42,7 +42,10 @@ export const configureApplication = Effect.fn("Application.configure")(function*
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
})
export function acquireApplicationLock() {
@@ -99,7 +102,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session"],
["data", "config", "cache", "state", "desktop", "session", "documents"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
+1 -1
View File
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
try {
const res = await fetch(new URL("/api/status", url), {
const res = await fetch(new URL("/api/info", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
+1 -1
View File
@@ -354,7 +354,7 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
const client = yield* HttpClient.HttpClient
return yield* client
.get(`${http.url}/api/status`, {
.get(`${http.url}/api/info`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
})
.pipe(
@@ -120,6 +120,10 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
if (process.platform === "darwin") app.focus({ steal: true })
win.focus()
}
runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {

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