Compare commits

..
103 changed files with 950 additions and 849 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.4",
"version": "2.0.5",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.4",
"version": "2.0.5",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+10 -2
View File
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
@@ -1,8 +1,9 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
@@ -405,12 +405,7 @@ test("executes a selected slash command after creating its worktree", async ({ p
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({
status: 200,
headers,
contentType: "application/json",
body: JSON.stringify({ data: { type: "prompt", inboxID: "msg_workspace_review" } }),
})
await route.fulfill({ status: 204, headers })
})
const editor = page.locator('[data-component="composer-editor"]')
await editor.fill("/review")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.4",
"version": "2.0.5",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"bin": {
+6 -23
View File
@@ -1,5 +1,4 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { Command } from "@opencode/schema/command"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -38,9 +37,6 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
/** `prompt` submissions admitted work under the start ID; `immediate` submissions have none to follow. */
export type Admission = Command.Outcome["type"]
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
@@ -80,7 +76,8 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<Admission>
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
@@ -117,14 +114,6 @@ export async function streamTurn(input: {
.catch(() => {})
}
// Command-spawned subagents run while the invoking Session is idle, so cancelling the prompt
// must interrupt them directly. Sessions that start after cancellation are interrupted on start.
const interruptChildren = async () => {
await Promise.all(
[...openChildren].map((sessionID) => input.client.session.interrupt({ sessionID }).catch(() => {})),
)
}
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
@@ -189,10 +178,9 @@ export async function streamTurn(input: {
if (!started) continue
if (event.type === "session.execution.started") {
if (mode === "turn" && control.cancelled) {
await input.client.session.interrupt({ sessionID: event.data.sessionID }).catch(() => {})
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
if (child) await notifyChild(child, { type: "status", status: "running" })
continue
}
@@ -354,15 +342,11 @@ export async function streamTurn(input: {
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
const onCancel = () => void interruptChildren()
control.admission.signal.addEventListener("abort", onCancel, { once: true })
try {
const admission = await input.submit(control.admission.signal).catch((error): Admission => {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
// The request may have been admitted before the abort reached the server; keep observing.
return "prompt"
})
if (admission === "immediate") {
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
@@ -400,7 +384,6 @@ export async function streamTurn(input: {
await completed.catch(() => {})
throw error
} finally {
control.admission.signal.removeEventListener("abort", onCancel)
if (!handedOff) await closeStream()
}
}
+5 -17
View File
@@ -52,7 +52,6 @@ import {
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type Admission,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
@@ -328,6 +327,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
@@ -362,12 +362,7 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
return { start, text, files, synthetic, slash, command }
}
async function submitPrompt(
client: OpenCodeClient,
session: Attached,
prompt: PreparedPrompt,
signal: AbortSignal,
): Promise<Admission> {
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
@@ -377,30 +372,23 @@ async function submitPrompt(
resume: false,
})
}
if (prompt.start.type === "compaction") {
await client.session.compact({ sessionID: session.id, id: prompt.start.id })
return "prompt"
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.command) {
// The command admits any resulting input under the turn's start ID so the stream can follow it.
const outcome = await client.session.command(
return client.session.command(
{
sessionID: session.id,
name: prompt.command.name,
id: prompt.start.id,
text: prompt.slash?.args ?? "",
files: prompt.files,
delivery: "steer",
},
{ signal },
)
return outcome.type
}
await client.session.prompt(
return client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
return "prompt"
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
@@ -1,138 +0,0 @@
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, lastUserText, newSession, type ChatRequest } from "./subprocess"
describe("acp slash command subprocess", () => {
test("template command stays pending until its model work completes and streams the output", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: { commands: { audit: { description: "Audit the change", template: "Audit $ARGUMENTS" } } },
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const gate = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await gate.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("Audit now")
// The response is held, so the prompt must still be pending.
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
gate.release("COMMAND_RESPONSE")
expect((await pending).stopReason).toBe("end_turn")
const chunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "COMMAND_RESPONSE"),
)
expect(chunk.params.sessionId).toBe(session.sessionId)
}, 60_000)
test("subagent command follows the child and the parent's follow-up before completing", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: {
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
},
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const child = script.hold()
const parent = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await child.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("You are a subagent spawned by another session.\nAudit now")
child.release("CHILD_DONE")
await parent.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toContain("CHILD_DONE")
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
parent.release("PARENT_DONE")
expect((await pending).stopReason).toBe("end_turn")
const childChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "CHILD_DONE"),
)
expect(childChunk.params.update._meta?.["opencode/child-session"]).toMatchObject({ parentID: session.sessionId })
const parentChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "PARENT_DONE"),
)
expect(parentChunk.params.update._meta?.["opencode/child-session"]).toBeUndefined()
}, 60_000)
test("cancelling during subagent command work interrupts the child and the parent's follow-up", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: {
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
},
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const child = script.hold()
const parent = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await child.started
await acp.notify("session/cancel", { sessionId: session.sessionId })
// Both model responses stay held, so the prompt can only settle by interrupting the child and
// the parent's follow-up run that the cancelled-subagent notice wakes.
expect((await pending).stopReason).toBe("cancelled")
child.release("LATE")
parent.release("LATE")
const followUp = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello again" }],
}),
)
expect(followUp.stopReason).toBe("end_turn")
}, 60_000)
})
/** Holds agent-loop model responses in FIFO order; auxiliary requests (no tools) answer immediately. */
function scriptedModel() {
const holds: Array<{ started: () => void; released: Promise<string> }> = []
return {
respond: (request: ChatRequest) => {
const next = request.tools ? holds.shift() : undefined
if (!next) return "accepted"
next.started()
return next.released
},
hold() {
const started = Promise.withResolvers<void>()
const released = Promise.withResolvers<string>()
holds.push({ started: started.resolve, released: released.promise })
return { started: started.promise, release: released.resolve }
},
}
}
function isAgentText(params: SessionNotification, text: string) {
return (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text" &&
params.update.content.text === text
)
}
+5 -7
View File
@@ -656,7 +656,6 @@ describe("acp event behavior", () => {
{ signal },
)
submitted.resolve()
return "prompt"
},
})
@@ -695,9 +694,10 @@ describe("acp event behavior", () => {
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session
.prompt({ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" }, { signal })
.then(() => "prompt" as const),
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
})
try {
@@ -784,9 +784,7 @@ function turn(input: {
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session
.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal })
.then(() => "prompt" as const),
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
})
}
+4 -3
View File
@@ -98,7 +98,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
start: { type: "input", id },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }).then(() => "prompt" as const),
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
expect(updates).toEqual([
@@ -122,7 +122,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
}
})
test("acp immediate admission resolves without prompt lifecycle events", async () => {
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
@@ -150,8 +150,9 @@ test("acp immediate admission resolves without prompt lifecycle events", async (
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => "immediate",
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
@@ -547,10 +547,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
start: { type: "input", id: inboxID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
fixture.client.session
.prompt({ sessionID, id: inboxID, text: "hello" }, { signal })
.then(() => "prompt" as const),
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
})
}
+1 -2
View File
@@ -12,7 +12,7 @@ describe("acp service prompt routing and usage", () => {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return Response.json({ data: { type: "immediate" } })
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
@@ -42,7 +42,6 @@ describe("acp service prompt routing and usage", () => {
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
name: "review",
id: expect.stringMatching(/^msg_/),
text: "now",
files: [],
delivery: "steer",
+5 -48
View File
@@ -4,7 +4,6 @@ import type {
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -47,7 +46,6 @@ type Waiter = {
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly notify: (method: string, params?: unknown) => Promise<void>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
@@ -66,38 +64,7 @@ description: Verifier compatibility skill.
# Verifier Skill
`
/** The subset of an OpenAI-compatible chat request that tests inspect. */
const ChatRequest = Schema.Struct({
messages: Schema.Array(
Schema.Struct({
role: Schema.String,
content: Schema.Union([
Schema.String,
Schema.Array(Schema.Struct({ text: Schema.String.pipe(Schema.optional) })),
]).pipe(Schema.optional),
}),
),
tools: Schema.Array(Schema.Unknown).pipe(Schema.optional),
})
export type ChatRequest = typeof ChatRequest.Type
const decodeChatRequest = Schema.decodeUnknownSync(ChatRequest)
/** Text of the most recent user message. */
export function lastUserText(request: ChatRequest) {
const content = request.messages.findLast((message) => message.role === "user")?.content
if (!Array.isArray(content)) return content
return content.flatMap((part) => (part.text === undefined ? [] : [part.text])).join("")
}
export type FixtureOptions = {
readonly skill?: string
/** Extra opencode.json entries merged over the verifier config. */
readonly config?: Record<string, Schema.Json>
/** Produce the scripted completion text; awaiting here holds the model response. */
readonly respond?: (request: ChatRequest) => string | Promise<string>
}
export async function createAcpFixture(options: FixtureOptions = {}) {
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
@@ -109,7 +76,7 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: ChatRequest[] = []
const requests: unknown[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
@@ -117,19 +84,15 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
const body = decodeChatRequest(await request.json())
requests.push(body)
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
headers: { "content-type": "text/event-stream" },
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify({
...verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined),
...options.config,
}),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
await Bun.write(models, "{}")
@@ -343,12 +306,6 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async notify(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const notification: JsonRpcNotification<unknown> = { jsonrpc: "2.0", method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(notification)}\n`))
await child.stdin.flush()
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+2 -3
View File
@@ -15,7 +15,6 @@ import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Command } from "@opencode/schema/command"
import type { Skill } from "@opencode/schema/skill"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
@@ -30,6 +29,7 @@ import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
import type { OpenCodeEvent } from "@opencode/protocol/groups/event"
import type { Pty } from "@opencode/schema/pty"
import type { PtyTicket } from "@opencode/schema/pty-ticket"
@@ -266,14 +266,13 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
export type SessionCommandInput = {
readonly sessionID: Session.ID
readonly name: string
readonly id?: SessionMessage.ID | undefined
readonly text: string
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type SessionCommandOutput = Command.Outcome
export type SessionCommandOutput = void
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -484,17 +484,13 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
params: { sessionID: input["sessionID"] },
payload: {
name: input["name"],
id: input["id"],
text: input["text"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -682,25 +682,24 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCommandOutput }>(
request<SessionCommandOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
name: input["name"],
id: input["id"],
text: input["text"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
},
successStatus: 200,
successStatus: 204,
declaredStatuses: [400, 401, 404, 500],
empty: false,
empty: true,
},
requestOptions,
).then((value) => value.data),
),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
+1 -29
View File
@@ -161,8 +161,6 @@ export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
export type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxCompactionPayload = {}
@@ -4109,7 +4107,6 @@ export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly name: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4127,29 +4124,8 @@ export type SessionCommandInput = {
}>
readonly delivery?: ("steer" | "queue") | null
}["name"]
readonly id?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
}["id"]
readonly text: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4169,7 +4145,6 @@ export type SessionCommandInput = {
}["text"]
readonly files?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4189,7 +4164,6 @@ export type SessionCommandInput = {
}["files"]
readonly agents?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4209,7 +4183,6 @@ export type SessionCommandInput = {
}["agents"]
readonly skills?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4229,7 +4202,6 @@ export type SessionCommandInput = {
}["skills"]
readonly delivery?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4249,7 +4221,7 @@ export type SessionCommandInput = {
}["delivery"]
}
export type SessionCommandOutput = { data: CommandOutcome }["data"]
export type SessionCommandOutput = void
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
+14 -6
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 and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **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.
- **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,11 +94,19 @@ 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.
### `Values`
### `Extension.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.
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`.
### OpenAPI tools
+19 -13
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, and Uint8Array values inside CodeMode.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, 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, custom synchronous
iterators, and synchronous generators.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, 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, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [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...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`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `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,10 @@ 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, 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, 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] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -450,7 +450,13 @@ 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.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
- [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.
## Extensions
@@ -460,8 +466,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`, `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`, `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),
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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.4",
"version": "2.0.5",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+2
View File
@@ -17,6 +17,7 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -69,6 +70,7 @@ 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)
@@ -24,6 +24,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { describeValue } from "./references.js"
@@ -49,6 +50,7 @@ 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))
@@ -96,6 +98,7 @@ export const extensionGlobals = <R>(
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)
@@ -11,6 +11,7 @@ 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"
@@ -80,6 +81,7 @@ 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,6 +84,7 @@ import {
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -653,7 +654,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, or URLSearchParams, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
node,
)
}
@@ -756,9 +757,11 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
@@ -1848,6 +1851,7 @@ 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,6 +29,7 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+11 -1
View File
@@ -156,6 +156,15 @@ 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(
@@ -181,13 +190,14 @@ 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 | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | 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,6 +16,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
@@ -85,6 +86,7 @@ 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,6 +12,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -66,6 +67,7 @@ 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
@@ -0,0 +1,119 @@
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
}
+27 -18
View File
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -126,6 +124,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
}
})
/**
* 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,
@@ -139,20 +160,8 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
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)))
}
}
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
+2
View File
@@ -13,6 +13,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -28,6 +29,7 @@ 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.
+28
View File
@@ -128,6 +128,34 @@ 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({
+148
View File
@@ -635,6 +635,154 @@ 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,10 +3,13 @@
* - 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"
@@ -166,3 +169,225 @@ 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([])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.4",
"version": "2.0.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.4",
"version": "2.0.5",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+3 -32
View File
@@ -4,7 +4,6 @@ import { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Bus } from "./bus.js"
@@ -12,23 +11,10 @@ import { State } from "./state.js"
export const Info = Command.Info
export type Info = Command.Info
export const Outcome = Command.Outcome
export type Outcome = Command.Outcome
export { Event } from "@opencode/schema/command"
export const immediate: Outcome = { type: "immediate" }
export function prompted(admitted: { readonly id: SessionMessage.ID }): Outcome {
return { type: "prompt", inboxID: admitted.id }
}
export interface Invocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Commands that prompt a Session must admit
* with this ID and report it in their outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
@@ -36,8 +22,7 @@ export interface Invocation {
export interface Definition {
readonly name: string
readonly description?: string
/** Resolve with an Outcome to report admitted work; `void` means the command finished immediately. */
readonly execute: (input: Invocation) => Effect.Effect<Outcome | void, unknown>
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Editor = {
@@ -60,7 +45,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly execute: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<Outcome, NotFoundError | ExecutionError>
}) => Effect.Effect<void, NotFoundError | ExecutionError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
@@ -97,22 +82,10 @@ export const layer = Layer.effect(
const definition = state.get().get(input.name)
if (!definition)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
const outcome = yield* definition.execute(input.invocation).pipe(
return yield* definition.execute(input.invocation).pipe(
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
)
if (outcome === undefined) return immediate
// Plugin callbacks cross a JavaScript boundary; only a well-formed outcome is reportable.
if (!isOutcome(outcome))
return yield* new ExecutionError({ command: input.name, message: "Command returned an invalid outcome" })
// Clients follow the invocation ID, so a command that admitted under another ID (typically by
// omitting `id: messageID` from its prompt) would leave them waiting forever. Fail loudly instead.
if (outcome.type === "prompt" && outcome.inboxID !== input.invocation.messageID)
return yield* new ExecutionError({
command: input.name,
message: `Command admitted ${outcome.inboxID} instead of the invocation message ID ${input.invocation.messageID}`,
})
return outcome
}),
})
}),
@@ -124,8 +97,6 @@ export const node = makeLocationNode({
deps: [Bus.node],
})
const isOutcome = Schema.is(Outcome)
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
+4 -8
View File
@@ -11,7 +11,6 @@ import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Command } from "../../command.js"
import { Location } from "../../location.js"
import { Session } from "../../session.js"
import { SubagentJob } from "../../session/subagent-job.js"
@@ -105,10 +104,9 @@ export const Plugin = define({
agent: selected.id,
model: model ?? selected.info?.model ?? parent.model,
})
const admitted = yield* sessions.prompt({
yield* sessions.prompt({
...input.prompt,
sessionID: child.id,
id: input.messageID,
text: ["You are a subagent spawned by another session.", text].join("\n"),
resume: false,
})
@@ -121,22 +119,20 @@ export const Plugin = define({
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return Command.prompted(admitted)
return
}
if (agent !== undefined) {
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
const admitted = yield* ctx.session.prompt({
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text,
delivery: input.delivery,
})
return Command.prompted(admitted)
}),
}).pipe(Effect.asVoid),
})
}
}
+4 -9
View File
@@ -3,7 +3,6 @@ export * as CommandPlugin from "./command.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Command } from "../command.js"
import { Location } from "../location.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
@@ -35,11 +34,10 @@ export const Plugin = define({
.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.map(Command.prompted)),
.pipe(Effect.asVoid),
})
editor.add({
name: "review",
@@ -49,11 +47,10 @@ export const Plugin = define({
.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.map(Command.prompted)),
.pipe(Effect.asVoid),
})
for (const prompt of loaded.prompts) {
editor.add({
@@ -70,18 +67,16 @@ export const Plugin = define({
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
const admitted = yield* ctx.session.prompt({
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
return Command.prompted(admitted)
}),
}).pipe(Effect.asVoid),
})
}
})
+2 -1
View File
@@ -167,7 +167,8 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
})
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = "websocket"
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+1 -1
View File
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = "websocket"
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
})
}
})
+1 -2
View File
@@ -186,13 +186,12 @@ export interface Interface {
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
id?: SessionMessage.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<Command.Outcome, NotFoundError | Command.NotFoundError | Command.ExecutionError>
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
+1 -4
View File
@@ -3,7 +3,6 @@ export * as SessionCommand from "./command.js"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { Command } from "../command.js"
import { Instance } from "../instance/service.js"
@@ -12,7 +11,6 @@ import { Plugin } from "../plugin/service.js"
export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
session: Session.Info
command: string
id?: SessionMessage.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
@@ -21,11 +19,10 @@ export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
}) {
const instances = yield* Instance.Service
const commands = yield* Plugin.awaitActivation.pipe(Effect.andThen(Command.Service), instances.provide(input.session))
return yield* commands.execute({
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.session.id,
messageID: input.id ?? SessionMessage.ID.create(),
prompt: {
text: input.text,
files: input.files,
+2 -34
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Command } from "@opencode/core/command"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
@@ -17,10 +16,7 @@ describe("Command", () => {
editor.add({
name: "goal",
description: "Manage the session goal",
execute: (input) =>
Effect.sync(() => {
calls.push(input)
}),
execute: (input) => Effect.sync(() => calls.push(input)),
})
})
@@ -29,41 +25,14 @@ describe("Command", () => {
)
const invocation = {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_goal"),
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
delivery: "steer" as const,
}
expect(yield* command.execute({ name: "goal", invocation })).toEqual(Command.immediate)
yield* command.execute({ name: "goal", invocation })
expect(calls).toEqual([invocation])
}),
)
it.effect("returns prompt outcomes and rejects ones admitted under another message ID", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.add({ name: "ask", execute: (input) => Effect.succeed(Command.prompted({ id: input.messageID })) })
editor.add({
name: "stray",
execute: () => Effect.succeed(Command.prompted({ id: SessionMessage.ID.make("msg_other") })),
})
})
const invocation = {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_ask"),
prompt: { text: "" },
delivery: "steer" as const,
}
expect(yield* command.execute({ name: "ask", invocation })).toEqual(
Command.prompted({ id: invocation.messageID }),
)
const error = yield* command.execute({ name: "stray", invocation }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Command.ExecutionError)
expect(error.message).toBe("Command admitted msg_other instead of the invocation message ID msg_ask")
}),
)
it.effect("replaces commands with later definitions", () =>
Effect.gen(function* () {
const command = yield* Command.Service
@@ -91,7 +60,6 @@ describe("Command", () => {
name: "fail",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_fail"),
prompt: { text: "" },
delivery: "steer",
},
@@ -13,7 +13,6 @@ import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
@@ -84,18 +83,12 @@ describe("command subagents", () => {
const gate = yield* llm.gate()
// This must return while the child's model is still blocked.
const outcome = yield* sessions.command({
sessionID: parent.id,
command: "review",
id: SessionMessage.ID.make("msg_review"),
text: "changes",
})
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
yield* gate.started
const children = (yield* sessions.list({ parentID: parent.id })).data
expect(children).toHaveLength(1)
const child = children[0]
if (!child) return yield* Effect.die("Expected a child session")
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_review") })
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
expect(yield* sessions.context(parent.id)).toEqual([])
+2 -5
View File
@@ -105,7 +105,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
Effect.sync(() => {
prompts.push({ text: input.text, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -129,7 +129,6 @@ describe("ConfigCommandPlugin.Plugin", () => {
name: "explain",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_explain"),
prompt: { text: item.input },
delivery: "queue",
},
@@ -177,7 +176,7 @@ Review files`,
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -211,7 +210,6 @@ Review files`,
name: "nested/docs",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_docs"),
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
@@ -238,7 +236,6 @@ Review files`,
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_latest"),
prompt: { text: "latest" },
delivery: "steer",
},
@@ -10,6 +10,9 @@ import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
import { Provider } from "@opencode/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
@@ -106,6 +109,50 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
for (const builtin of [
{ id: "openai", model: "gpt-5.6-sol", package: "@opencode/ai/providers/openai/responses", plugin: OpenAIPlugin },
{ id: "xai", model: "grok-4.6", package: "@opencode/ai/providers/xai", plugin: XAIPlugin },
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const providerID = Provider.ID.make(builtin.id)
const modelID = Model.ID.make(builtin.model)
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.activation = "enabled"
provider.package = builtin.package
})
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+1 -7
View File
@@ -11,7 +11,6 @@ import { PluginModule } from "@opencode/core/plugin/module"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { fromPromise } from "@opencode/plugin/promise/adapter"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -498,12 +497,7 @@ it.effect("reloading a plugin replaces its command implementation", () =>
])
const request = {
name: "greet",
invocation: {
sessionID: Session.ID.make("ses_plugin"),
messageID: SessionMessage.ID.make("msg_greet"),
prompt: { text: "" },
delivery: "steer" as const,
},
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
}
yield* load("1", "before")
+3 -13
View File
@@ -37,7 +37,6 @@ describe("CommandPlugin.Plugin", () => {
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: {
id?: string
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
@@ -52,9 +51,9 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ id: input.id, text: input.text, files: input.files, delivery: input.delivery })
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -79,11 +78,10 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
})
const outcome = yield* command.execute({
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_init"),
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
@@ -92,7 +90,6 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_review"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
@@ -101,7 +98,6 @@ describe("CommandPlugin.Plugin", () => {
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_init_empty"),
prompt: { text: "" },
delivery: "steer",
},
@@ -110,33 +106,27 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_review_empty"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_init") })
expect(prompts).toEqual([
{
id: "msg_init",
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
id: "msg_review",
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
id: "msg_init_empty",
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
id: "msg_review_empty",
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
@@ -586,26 +586,26 @@ describe("OpencodePlugin", () => {
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("9 minutes")
yield* TestClock.adjust("50 seconds")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("10 seconds")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
yield* TestClock.adjust("10 minutes")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.4",
"version": "2.0.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.4",
"version": "2.0.5",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.4",
"version": "2.0.5",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -11
View File
@@ -1,30 +1,20 @@
import type { CommandApi } from "@opencode/client/effect/api"
import type { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
* `prompt` outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
export type CommandOutcome = Command.Outcome
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly execute: (input: CommandInvocation) => Effect.Effect<CommandOutcome | void, unknown>
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
}
export interface CommandEditor {
+1 -11
View File
@@ -1,29 +1,19 @@
import type { CommandApi } from "@opencode/client/promise/api"
import type { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
* `prompt` outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
export type CommandOutcome = Command.Outcome
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly execute: (input: CommandInvocation) => Promise<CommandOutcome | void>
readonly execute: (input: CommandInvocation) => Promise<void>
}
export interface CommandEditor {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12493,37 +12467,6 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+5 -8
View File
@@ -33,7 +33,6 @@ import {
UnknownError,
} from "../errors.js"
import { Agent } from "@opencode/schema/agent"
import { Command } from "@opencode/schema/command"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
@@ -415,13 +414,10 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({
name: Schema.String,
id: SessionMessage.ID.pipe(Schema.optional).annotate({
description: "Message ID for input the command admits, so its resulting work can be correlated.",
}),
...PromptInput.Prompt.fields,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Command.Outcome }),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
})
.middleware(sessionLocationMiddleware)
@@ -429,8 +425,7 @@ export const makeSessionGroup = <
OpenApi.annotations({
identifier: "session.command",
summary: "Run command",
description:
"Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
description: "Execute a slash command callback immediately.",
}),
),
)
@@ -563,7 +558,9 @@ export const makeSessionGroup = <
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
.annotateMerge(
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
-17
View File
@@ -3,7 +3,6 @@ export * as Command from "./command.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
import { SessionMessage } from "./session-message.js"
const Updated = ephemeral({ type: "command.updated", schema: {} })
@@ -13,22 +12,6 @@ export const Info = Schema.Struct({
description: Schema.String.pipe(optional),
}).annotate({ identifier: "Command.Info" })
/**
* What a command did when invoked. `immediate` commands finish inside the request;
* `prompt` commands durably admitted session input whose resulting work clients
* follow through the invocation message ID.
*/
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.tag("immediate") }),
Schema.Struct({
type: Schema.tag("prompt"),
inboxID: SessionMessage.ID.annotate({
description: "Admitted inbox item ID. Must equal the invocation message ID.",
}),
}),
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Command.Outcome" }))
export type Outcome = typeof Outcome.Type
export const Event = {
Updated,
Definitions: inventory(Updated),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.4",
"version": "2.0.5",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+2 -3
View File
@@ -336,11 +336,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.command",
Effect.fn(function* (ctx) {
const outcome = yield* session
yield* session
.command({
sessionID: ctx.params.sessionID,
command: ctx.payload.name,
id: ctx.payload.id,
text: ctx.payload.text,
files: ctx.payload.files,
agents: ctx.payload.agents,
@@ -366,7 +365,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
),
),
)
return { data: outcome }
return HttpApiSchema.NoContent.make()
}),
)
.handle(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -38,6 +38,22 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("merges union alpha routes without exposing the provider", () => {
;["opencode-go/union-alpha", "opencode/union-alpha"].forEach((model) => {
expect(statModel(model, "")).toBe("union-alpha")
expect(statProvider(model, "gpt-test-model", "test-provider")).toBe("unknown")
expect(
toModelAggregate({ ...aggregate(model, "test-provider"), provider_model: "gpt-test-model" }),
).toMatchObject([{ model: "union-alpha", provider: "unknown", requests: 1 }])
expect(toProviderAggregate(aggregate(model, "test-provider"))).toMatchObject([
{ provider: "unknown", requests: 1 },
])
expect(toGeoAggregate({ ...aggregate(model, "test-provider"), country: "US" })).toMatchObject([
{ model: "union-alpha", provider: "unknown", country: "US", requests: 1 },
])
})
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
@@ -94,6 +110,20 @@ describe("inference stat normalization", () => {
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
test("normalizes union alpha routes in stat queries", () => {
process.env.SST_RESOURCE_InferenceEvent = JSON.stringify({
catalog: "inference",
database: "events",
table: "generation",
})
process.env.SST_RESOURCE_StatsSyncConfig = JSON.stringify({ dataset: "zen" })
const query = buildStatsQuery(new Date("2026-05-20T00:00:00.000Z"), new Date("2026-05-21T00:00:00.000Z"))
expect(query).toContain("WHEN lower(model) = 'opencode-go/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) = 'opencode/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) IN ('omen-alpha', 'union-alpha') THEN 'unknown'")
})
})
function aggregate(model: string, provider: string) {
@@ -7,6 +7,7 @@ import {
MODEL_AUTHOR_RULES,
MODEL_NAME_ALIASES,
RETIRED_STAT_PROVIDERS,
STEALTH_MODELS,
statModel,
statProvider,
} from "./model-normalization"
@@ -263,6 +264,7 @@ ${Object.entries(MODEL_NAME_ALIASES)
function statProviderSql(model: string, providerModel: string, provider: string) {
return `CASE
WHEN lower(${model}) IN (${[...STEALTH_MODELS].map(sqlString).join(", ")}) THEN 'unknown'
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${providerModel}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
WHEN ${provider} <> '' AND lower(${provider}) NOT IN (${RETIRED_STAT_PROVIDERS.map(sqlString).join(", ")}) THEN ${provider}
@@ -13,8 +13,11 @@ export const MODEL_AUTHOR_RULES = [
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const STEALTH_MODELS = new Set(["omen-alpha", "union-alpha"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"opencode-go/union-alpha": "union-alpha",
"opencode/union-alpha": "union-alpha",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
@@ -45,7 +48,10 @@ export function statProvider(
providerModel: string | undefined,
provider: string | undefined,
) {
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
const normalized = statModel(model, providerModel)
if (STEALTH_MODELS.has(normalized.toLowerCase())) return "unknown"
const modelAuthorValue = modelAuthor(normalized)
if (!modelAuthorValue) return undefined
const providerModelAuthor = modelAuthor(providerModel)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -128,7 +128,7 @@ export function createTuiAttention(input: {
return {
async notify(request) {
try {
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (!input.config.attention.notifications && !input.config.attention.sound) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
+3 -12
View File
@@ -253,20 +253,11 @@ export const settings: Setting[] = [
format: (value) => `${value} ms`,
keywords: ["leader key", "shortcut timeout"],
},
{
title: "Attention",
category: "Alerts",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["alerts"],
},
{
title: "Notifications",
category: "Alerts",
path: ["attention", "notifications"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
@@ -275,10 +266,10 @@ export const settings: Setting[] = [
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects"],
keywords: ["audio", "sound effects", "alerts"],
},
{
title: "Volume",
+2 -5
View File
@@ -95,7 +95,6 @@ export const Info = Schema.Struct({
).annotate({ description: "Scrolling behavior" }),
attention: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
volume: Schema.optional(
@@ -242,7 +241,6 @@ export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
@@ -285,9 +283,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
return {
...input,
attention: {
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
notifications: input.attention?.notifications ?? false,
sound: input.attention?.sound ?? false,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
+1 -2
View File
@@ -1751,8 +1751,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
// Command outcomes are not inbox items; the event stream observes any admitted work.
await client.session.command(
return client.session.command(
{
sessionID: input.sessionID,
name: command.name,
@@ -712,9 +712,9 @@ describe("V2 mini transport", () => {
})
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(
ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
).toEqual(["Done."])
expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([
"Done.",
])
await transport.close()
})
@@ -742,11 +742,7 @@ describe("V2 mini transport", () => {
model: { providerID: "test", id: "model" },
content: [
{ type: "text", text: "I'll check." },
canonicalToolPart("read", {
status: "completed",
input: {},
content: [{ type: "text", text: "file" }],
}),
canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }),
],
time: { created: 2, completed: 3 },
},
@@ -763,9 +759,7 @@ describe("V2 mini transport", () => {
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(
ui.commits
.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool")
.map((commit) => commit.text),
ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
).toEqual(["what happened", "Done."])
await transport.close()
})
@@ -2935,7 +2929,10 @@ describe("V2 mini transport", () => {
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
{ signal: undefined },
)
expect(defaultModel).toHaveBeenCalledWith({ location: { directory: "/project" } }, { signal: undefined })
expect(defaultModel).toHaveBeenCalledWith(
{ location: { directory: "/project" } },
{ signal: undefined },
)
await transport.close()
})
@@ -3409,7 +3406,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1" },
})
})
return ok({ type: "prompt" as const, inboxID: "msg_cmd" })
return ok(undefined)
})
await transport.runPromptTurn({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.4",
"version": "2.0.5",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.4",
"version": "2.0.5",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"scripts": {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12493,37 +12467,6 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12493,37 +12467,6 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {

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