Compare commits

..
Author SHA1 Message Date
opencode-agent[bot]andvimtor c7dd0c8278 fix(tui): match upgrade alias to update command (#48204)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-09 20:16:23 +02:00
Aiden Cline 85dff53a1f fix(codemode): label supported and unsupported syntax in the hint (#48200) 2026-09-09 12:33:22 -05:00
Aiden Cline 297019e321 fix(codemode): report non-constructible new callees as TypeError (#48083) 2026-09-09 12:22:44 -05:00
Aiden Cline 95503c1773 fix(core): send reasoning.effort for GPT-5.6+ on Bedrock Converse (#48195) 2026-09-09 12:02:31 -05:00
Aiden Cline ba1448325a fix(core): exclude invalid Bedrock entries from models.dev imports (#48081) 2026-09-09 11:36:14 -05:00
be2582f316 feat(plugin): decompose tab controls (#48129)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-09-09 18:14:33 +02:00
opencode-agent[bot]andjlongster c0cb1c7a91 fix(tui): limit shell list commands to one line (#48184)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-09 11:09:32 -04:00
opencode-agent[bot]andjlongster e10408b219 fix(cli): ignore upgrade cleanup failures (#48178)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-09 10:51:17 -04:00
Shoubhit Dash bdc143c1d5 feat(core): enable the responses websocket by default (#48140) 2026-09-09 20:13:31 +05:30
Shoubhit Dash d52380024d fix(core): drop gpt-5.4 models from the Codex allowlist (#48141) 2026-09-09 20:04:46 +05:30
Kit Langton f1eed8bf11 fix(core): preserve legacy markdown agent variants 2026-09-09 10:02:03 -04:00
54 changed files with 647 additions and 304 deletions
-8
View File
@@ -1,8 +0,0 @@
# Slash-command follow-up behavior
Both screenshots submit `/review current changes` with the follow-up preference set to **Queue** while a session is running.
- `before.png`: production build of `v2` at `1dcc6551d9`; the command is sent as Steer.
- `after.png`: production build with this fix; the command appears in the queue above the composer.
Captured with the fixture-backed `slash commands respect queue preference and alternate submit` Playwright scenario in `packages/app/e2e/regression/session-queue.spec.ts`. The production UI is rendered against an isolated command/session fixture.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -153,6 +153,15 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
// no code. Classified failures such as context overflow keep their runner-owned recovery.
if (
create.mode === "incremental" &&
observation.error.reason._tag === "InvalidRequest" &&
observation.error.reason.classification === undefined
)
return rejected(observation, "retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
+5 -2
View File
@@ -115,8 +115,11 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}
const onAbort = () => {
cleanup()
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
ws.close(1000)
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
// after cleanup, EventEmitter would throw it as an uncaught exception.
ws.addEventListener("error", () => {}, { once: true })
ws.close(1000)
}
const onOpen = () => {
cleanup()
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -30,6 +30,7 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
@@ -69,14 +70,34 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
},
})
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
const base = baseChannelDriver(message)
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
return {
...base,
observe: (create, frame) =>
base.observe(create, frame).pipe(
Effect.map((observation) =>
observation.type === "provider-failure"
? {
...observation,
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
}
: observation,
),
),
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: baseChannelDriver(message),
base: base(message),
})
}
@@ -852,6 +873,53 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(firstRequest, classifyingChannelDriver)
const saved = checkpoint(
yield* first.observe(
yield* first.create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
},
classifyingChannelDriver,
)
// Codex reports a stale previous_response_id as a plain invalid_request_error.
const stale = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
})
const incremental = yield* second.create(saved)
expect(incremental.mode).toBe("incremental")
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
// A full send has no continuation to blame, so the same error stays a provider failure.
const full = yield* second.create(undefined)
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
const overflow = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
})
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -139,15 +139,9 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
commands: [{ name: "review", description: "Review current changes" }],
onInboxChange: mock.onInboxChange,
events: mock.events,
})
await page.route(`**/api/session/${sessionID}/command`, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>
mock.onPrompt({ sessionID, body: { ...body, text: `Review ${body.text}` } })
await route.fulfill({ status: 204 })
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
@@ -173,31 +167,6 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
await expect(view.input).toHaveText("")
})
for (const behavior of ["queue", "steer"] as const) {
test(`slash commands respect ${behavior} preference and alternate submit`, async ({ page }, testInfo) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, behavior)
await expect(view.input).toBeEditable()
await view.input.fill("/review current changes")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText(
behavior === "queue" ? "Steer" : "Queue",
)
await view.input.press("Enter")
await expect(page.getByText("Review current changes", { exact: true })).toBeVisible()
await expect(view.input).toHaveText("")
await page.screenshot({ path: testInfo.outputPath("slash-command-delivery.png") })
expect(mock.prompts.map((prompt) => prompt.delivery)).toEqual([behavior])
await expect(view.rows).toHaveCount(behavior === "queue" ? 1 : 0)
await view.input.fill("/review the retry path")
await view.input.press("ControlOrMeta+Enter")
await expect
.poll(() => mock.prompts.map((prompt) => prompt.delivery))
.toEqual([behavior, behavior === "queue" ? "steer" : "queue"])
await expect(view.rows).toHaveCount(1)
})
}
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
+1 -2
View File
@@ -38,7 +38,6 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
commands?: { name: string; description?: string }[]
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
@@ -255,7 +254,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: config.commands ?? [] }),
command: () => Effect.succeed({ location: location(config), data: [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
-50
View File
@@ -65,14 +65,12 @@ function submitInput(
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
delivery?: Parameters<typeof createComposerSubmit>[0]["delivery"],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
delivery,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -130,54 +128,6 @@ function session(input: {
}
describe("Composer submission", () => {
test.each([
{ delivery: "queue" as const, alternate: false },
{ delivery: "steer" as const, alternate: false },
{ delivery: "queue" as const, alternate: true },
{ delivery: "steer" as const, alternate: true },
])("submits slash commands with $delivery delivery and alternate=$alternate", async ({ delivery, alternate }) => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["api"]["command"]>[0]>()
const target = session({
calls,
current: () => ({ agent: "plan", model: { id: "old", providerID: "old" } }),
prompt: async () => {
throw new Error("command must not call prompt")
},
command: async (request) => {
calls.push("command")
admitted.resolve(request)
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => true,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [{ name: "review" }],
undefined,
(value) => {
expect(value).toBe(alternate)
return delivery
},
).submit(new Event("submit"), { alternate })
expect(await admitted.promise).toMatchObject({ command: "review", text: "changes", delivery })
expect(calls).toEqual(delivery === "queue" ? ["command"] : ["switch-agent", "switch-model", "command"])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
+10 -5
View File
@@ -122,9 +122,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -316,8 +322,7 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
+5 -1
View File
@@ -100,7 +100,11 @@ function packageNames() {
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
if (fs.existsSync(targetBinary)) {
try {
fs.unlinkSync(targetBinary)
} catch {}
}
try {
fs.linkSync(source, targetBinary)
} catch {
+7 -2
View File
@@ -167,6 +167,11 @@ const make = Effect.gen(function* () {
const latest = () => release().pipe(Effect.map((data) => data.version))
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -192,12 +197,12 @@ const make = Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const cache = yield* temporaryDirectory("update-")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
+21 -1
View File
@@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
@@ -18,6 +18,7 @@ function fixture(
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -57,6 +58,17 @@ function fixture(
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
remove: (target, options) =>
failCleanup && target.startsWith(global.cache)
? Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "remove",
pathOrDescriptor: target,
}),
)
: fs.remove(target, options),
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
@@ -125,6 +137,14 @@ installs.forEach(({ method, command }) => {
}),
)
})
it.live("bun ignores install cache cleanup failures", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", true)
yield* test.updater.upgrade("bun", "v2.3.4-beta.1")
expect(test.commands).toHaveLength(1)
}),
)
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
@@ -1369,6 +1369,7 @@ export type ProviderInfo = {
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1849,6 +1850,7 @@ export type ModelInfo = {
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -2025,6 +2027,7 @@ export type ConfigEntry =
providers?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
canonical?: string
name?: string
env?: Array<string>
@@ -2035,6 +2038,7 @@ export type ConfigEntry =
models?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
modelID?: string
family?: string
name?: string
+6 -2
View File
@@ -138,7 +138,10 @@ ultimate source of truth.
- [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.
- [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] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -367,7 +370,8 @@ ultimate source of truth.
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
+4 -3
View File
@@ -88,9 +88,6 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName = "Error"
@@ -112,6 +109,10 @@ export class InterpreterRuntimeError extends Error {
}
}
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
+13 -1
View File
@@ -1264,7 +1264,19 @@ class Frame<R> {
const callee = yield* self.evaluateExpression(node.callee)
// Globals are built with this interpreter's R; `instanceof` cannot recover the type argument.
const construct = callee instanceof HostFunction ? (callee as HostFunction<R>).construct : undefined
if (construct === undefined) throw unsupportedSyntax("NewExpression", node)
if (construct === undefined) {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const message =
callee instanceof CodeModeFunction
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
: callee instanceof HostFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw new InterpreterRuntimeError(message, node).as("TypeError")
}
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* construct(args, node)
})
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// `new` is supported syntax; only the callee decides whether construction succeeds. A callee without
// construction support is a TypeError naming it, like JS, rather than an unsupported-syntax diagnostic
// that would suggest `new` itself is unavailable.
const tools = {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
}
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("new on a non-constructible callee", () => {
test("built-in functions without construction point at the plain call", async () => {
// Number is a real constructor in JS, so the message must not claim otherwise.
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toStartWith("new Number(...) is not supported; call Number(...) without new instead.")
expect(failure.suggestions).toBeUndefined()
expect((await error(`return new String("a")`)).message).toStartWith("new String(...) is not supported")
expect((await error(`return new Math.abs(1)`)).message).toStartWith(
"new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
)
})
test("non-callable values are not constructors", async () => {
expect((await error(`return new tools.echo()`)).message).toStartWith("tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith("Date is not a constructor.")
})
test("user-defined functions explain the documented gap", async () => {
const failure = await error(`function Point(x) { return { x } }; return new Point(1)`)
expect(failure.message).toStartWith(
"Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
)
expect((await error(`const make = () => ({}); return new make()`)).message).toStartWith(
"make cannot be constructed",
)
})
test("the failure is a catchable TypeError", async () => {
expect(
await value(`
try { new Number(1) } catch (error) { return [error.name, error instanceof TypeError] }
`),
).toEqual(["TypeError", true])
})
test("an undeclared callee still fails as an unknown identifier", async () => {
expect((await error(`return new Function("return 1")`)).message).toContain("Function")
expect((await error(`return new Function("return 1")`)).message).not.toContain("not a constructor")
})
test("classes remain unsupported syntax", async () => {
const failure = await error(`class A {}; return new A()`)
expect(failure.kind).toBe("UnsupportedSyntax")
expect(failure.message).toStartWith(
"Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
)
})
})
+5 -3
View File
@@ -55,7 +55,9 @@ describe("Number and Math", () => {
})
test("Number valueOf does not enable boxed numbers", async () => {
expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax")
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toContain("new Number(...) is not supported; call Number(...) without new instead.")
})
})
@@ -725,9 +727,9 @@ describe("stdlib integration", () => {
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
expect(await value(`const t = { M: Map }; return new t.M() instanceof Map`)).toBe(true)
const shadowed = await error(`const Date = 5; return new Date()`)
expect(shadowed.kind).toBe("UnsupportedSyntax")
expect(shadowed.message).toStartWith("Date is not a constructor.")
const fn = await error(`const f = () => 1; return new f()`)
expect(fn.kind).toBe("UnsupportedSyntax")
expect(fn.message).toStartWith("f cannot be constructed")
})
test("Object.is uses SameValue semantics", async () => {
+8 -1
View File
@@ -210,6 +210,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
const reasoning = isRecord(settings.reasoningConfig) ? settings.reasoningConfig : undefined
const anthropic = input.modelID.includes("anthropic")
const openai = input.modelID.includes("openai.")
// Converse passes OpenAI fields through verbatim. gpt-oss (Harmony) takes the
// flat chat-completions `reasoning_effort`; GPT-5.6+ reject it and take the
// Responses-style `reasoning.effort` instead.
const harmony = input.modelID.includes("openai.gpt-oss")
const effort = typeof reasoning?.maxReasoningEffort === "string" ? reasoning.maxReasoningEffort : undefined
const type = typeof reasoning?.type === "string" ? reasoning.type : undefined
const budget = typeof reasoning?.budgetTokens === "number" ? reasoning.budgetTokens : undefined
@@ -236,7 +240,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
},
}
: {}),
...(!anthropic && openai && effort !== undefined ? { reasoning_effort: effort } : {}),
...(!anthropic && openai && harmony && effort !== undefined ? { reasoning_effort: effort } : {}),
...(!anthropic && openai && !harmony && effort !== undefined
? { reasoning: { ...(isRecord(additional.reasoning) ? additional.reasoning : {}), effort } }
: {}),
...(!anthropic && !openai && effort !== undefined
? {
reasoningConfig: {
+1
View File
@@ -77,6 +77,7 @@ const layer = Layer.effect(
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
websocket: model.websocket ?? provider.websocket,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+10 -3
View File
@@ -184,6 +184,15 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
.replace(/\.md$/, "")
const body = markdown.content.trim()
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
// Join legacy model + variant without sending native request/permissions through migration.
// Embedded and structured native selections, and a variant without a model, stay unchanged.
const data =
typeof markdown.data.model === "string" &&
!markdown.data.model.includes("#") &&
typeof markdown.data.variant === "string" &&
/^[^#]+$/.test(markdown.data.variant)
? { ...markdown.data, model: `${markdown.data.model}#${markdown.data.variant}` }
: markdown.data
const agent = legacy
? Option.getOrUndefined(
Option.map(
@@ -191,9 +200,7 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
ConfigMigrateV1.migrateAgent,
),
)
: Option.getOrUndefined(
decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
)
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
if (!agent) return
const info = Option.getOrUndefined(
decodeConfig({
@@ -58,6 +58,7 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.websocket !== undefined) provider.websocket = item.websocket
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -78,6 +79,7 @@ export const Plugin = define({
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.websocket !== undefined) model.websocket = config.websocket
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+3
View File
@@ -85,6 +85,8 @@ export interface Resolved {
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
/** Whether the session WebSocket may carry this model's requests when the route supports it. */
readonly websocket: boolean
}
export interface Interface {
@@ -321,6 +323,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
websocket: selected.websocket ?? true,
}
})
return Service.of({
+23
View File
@@ -5,6 +5,24 @@ import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
// Opus/Sonnet 4.6 support in-region calls in eu-west-2 and must remain available.
const BEDROCK_PROFILE_ONLY_IDS = [
"amazon.nova-2-lite-v1:0",
"anthropic.claude-fable-5",
"anthropic.claude-fable-5-1",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-5",
"deepseek.r1-v1:0",
"mistral.pixtral-large-2502-v1:0",
]
export const ModelsDevPlugin = define({
id: "opencode.models.dev",
effect: Effect.fn(function* (ctx) {
@@ -39,6 +57,11 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
if (
provider.info.id === Provider.ID.amazonBedrock &&
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
)
continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
}
}
@@ -18,32 +18,6 @@ const isBedrock = (item: { readonly package: string }) => {
return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode/ai/providers/amazon-bedrock")
}
// Bare Bedrock model IDs that AWS rejects unless sent as an inference-profile
// ID (`us.`/`eu.`/`global.`/...). Verified via on-demand foundation-model
// listings across six regions plus live Converse probes, all returning "with
// on-demand throughput isn't supported. Retry ... with an inference profile".
// V1 rewrites these to profiles at request time so they must stay in
// models.dev; V2 sends IDs verbatim, so listing them only produces errors.
// Interim until per-entry source-region metadata lands; region-aware
// filtering will subsume this list then.
export const PROFILE_ONLY_BARE_IDS = [
"amazon.nova-2-lite-v1:0",
"anthropic.claude-fable-5",
"anthropic.claude-fable-5-1",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-5",
"deepseek.r1-v1:0",
"mistral.pixtral-large-2502-v1:0",
]
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon.bedrock",
effect: Effect.fn(function* (ctx) {
@@ -79,12 +53,6 @@ export const AmazonBedrockPlugin = define({
}
delete provider.settings.endpoint
})
for (const modelID of PROFILE_ONLY_BARE_IDS) {
if (!evt.model.get(item.provider.id, modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
model.enabled = false
})
}
}
})
}),
+2 -1
View File
@@ -20,7 +20,8 @@ const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
// ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna).
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
+6 -12
View File
@@ -6,7 +6,7 @@ import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Content } from "@opencode/schema/tool"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { Cause, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { App } from "../app.js"
@@ -27,9 +27,6 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -364,13 +361,6 @@ export const layer = Layer.effect(
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = hasHttpHooks
? httpMiddleware(hooks, {
sessionID: session.id,
@@ -379,9 +369,13 @@ export const layer = Layer.effect(
kind: input.kind,
})
: undefined
// HTTP hooks must observe every request, so they keep the provider on HTTP.
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" && webSocket && !hasHttpHooks
...(input.webSocket === "session" &&
!hasHttpHooks &&
resolved.capabilities.responsesWebsockets === true &&
resolved.websocket
? { webSocket: transport.bind(session.id) }
: {}),
}
+31 -15
View File
@@ -20,6 +20,7 @@ import { webSocketConstructor } from "../effect/app-node-platform.js"
const ROTATE_AFTER_MS = 55 * 60 * 1000
const INBOUND_CAPACITY = 128
const CONNECT_TIMEOUT = "10 seconds"
const IDLE_TIMEOUT = "5 minutes"
const events = Metric.counter("opencode_session_websocket_events_total", {
description: "Session WebSocket lifecycle events",
@@ -167,7 +168,20 @@ export const makeLayer = (connector: WebSocketConnector) =>
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const connection = yield* restore(
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
connector.open(exchange.connect).pipe(
Effect.timeoutOrElse({
duration: CONNECT_TIMEOUT,
orElse: () =>
transportError("Timed out opening the Session WebSocket", {
url: exchange.connect.url,
operation: "request",
code: "connect-timeout",
phase: "connect",
delivery: "not-sent",
}),
}),
Effect.withSpan("SessionModelTransport.connect"),
),
)
if (owner.closed) {
yield* connection.close
@@ -294,20 +308,22 @@ export const makeLayer = (connector: WebSocketConnector) =>
const channel = owner.channel
? owner.channel
: yield* open(owner, exchange, key).pipe(
Effect.catch((error) =>
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
? Effect.fail(error)
: Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.as(undefined),
),
),
Effect.catch((error) => {
if (error.reason._tag === "Transport" && error.reason.code === "owner-closed") return Effect.fail(error)
// Any connect failure, transient or not, pins the Session to HTTP until restart or move:
// a network that refuses the upgrade would otherwise charge every step for a failed connect.
owner.httpFallback = true
return Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.as(undefined),
)
}),
)
if (!channel) return fallback(exchange)
@@ -60,6 +60,7 @@ export const resolved = (
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
readonly websocket?: boolean
},
): Resolved => ({
model,
@@ -72,6 +73,7 @@ export const resolved = (
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
websocket: options.websocket ?? true,
})
const layer = Layer.effect(
+3 -1
View File
@@ -35,8 +35,10 @@ export function isRetryable(error: AIError) {
case "RateLimit":
case "ProviderInternal":
return true
// HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected
// requests as final; not-sent and ambiguous (no frame observed) are still pre-output.
case "Transport":
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
+20 -3
View File
@@ -251,11 +251,28 @@ describe("AISDKNative", () => {
},
})
for (const modelID of ["openai.gpt-oss-120b-1:0", "global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol"]) {
// gpt-oss (Harmony) keeps the flat chat-completions field.
expect(
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, "openai.gpt-oss-120b-1:0")
?.body,
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
// GPT-5.6+ reject `reasoning_effort` and take the Responses-style nested field.
for (const modelID of ["global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol", "us.openai.gpt-6-astra"]) {
expect(
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, modelID)?.body,
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body,
).toEqual({ additionalModelRequestFields: { reasoning: { effort: "none" } } })
}
expect(
map(
"@ai-sdk/amazon-bedrock",
{
reasoningConfig: { maxReasoningEffort: "high" },
additionalModelRequestFields: { reasoning: { summary: "auto" } },
},
"us.openai.gpt-5.6-sol",
)?.body,
).toEqual({ additionalModelRequestFields: { reasoning: { summary: "auto", effort: "high" } } })
})
test("maps Bedrock Mantle models to their supported native APIs", () => {
+92 -1
View File
@@ -6,6 +6,7 @@ import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Directory, Document, Event, Info } from "@opencode/schema/config"
import { Model } from "@opencode/schema/model"
import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -17,7 +18,7 @@ import { AbsolutePath } from "@opencode/core/schema"
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
@@ -60,6 +61,76 @@ test("keeps schema fields and name out of legacy agent options", () => {
})
describe("ConfigAgentPlugin.Plugin", () => {
for (const item of [
{ name: "separate legacy variant", frontmatter: "model: example/chat\nvariant: high", model: "example/chat#high" },
{ name: "unqualified model", frontmatter: "model: example/chat", model: "example/chat" },
{ name: "embedded native variant", frontmatter: "model: example/chat#high", model: "example/chat#high" },
{
name: "structured native variant",
frontmatter: "model:\n providerID: example\n model: chat\n variant: high",
model: "example/chat#high",
},
{
name: "structured unqualified model",
frontmatter: "model:\n providerID: example\n model: chat",
model: "example/chat",
},
{ name: "standalone variant", frontmatter: "variant: high", model: undefined },
{
name: "embedded native variant with an ignored separate variant",
frontmatter: "model: example/chat#high\nvariant: low",
model: "example/chat#high",
},
{
name: "structured native variant with an ignored separate variant",
frontmatter: "model:\n providerID: example\n model: chat\n variant: high\nvariant: low",
model: "example/chat#high",
},
]) {
for (const native of [false, true]) {
it.live(`loads Markdown ${item.name}${native ? " with native request and permissions" : ""}`, () =>
Effect.gen(function* () {
const agent = yield* loadMarkdownAgent(
native
? `${item.frontmatter}
request:
headers:
x-agent: native
body:
effort: high
permissions:
- action: edit
resource: "*"
effect: deny`
: item.frontmatter,
)
expect(agent.model).toEqual(item.model === undefined ? undefined : Model.Ref.parse(item.model))
expect(agent.request).toEqual({
settings: {},
headers: native ? { "x-agent": "native" } : {},
body: native ? { effort: "high" } : {},
})
if (native) {
expect(agent.permissions).toContainEqual({ action: "edit", resource: "*", effect: "deny" })
expect(Permission.evaluate("edit", "example.txt", agent.permissions).effect).toBe("deny")
}
}),
)
}
}
for (const variant of [undefined, "high"]) {
it.live(`loads Markdown legacy temperature ${variant ? "with" : "without"} a separate variant`, () =>
Effect.gen(function* () {
const agent = yield* loadMarkdownAgent(
`model: example/chat\ntemperature: 0.5${variant ? `\nvariant: ${variant}` : ""}`,
)
expect(agent.model).toEqual(Model.Ref.parse(variant ? "example/chat#high" : "example/chat"))
expect(agent.request).toEqual({ settings: {}, headers: {}, body: { temperature: 0.5 } })
}),
)
}
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
@@ -560,6 +631,26 @@ Use native v2 fields.`,
)
})
function loadMarkdownAgent(frontmatter: string) {
return Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const fs = yield* FSUtil.Service
yield* fs.makeDirectory(path.join(tmp.path, "agents"))
yield* fs.writeFileString(
path.join(tmp.path, "agents", "reviewer.md"),
`---\n${frontmatter}\n---\nReview carefully.`,
)
const agents = yield* Agent.Service
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer([directoryEntry(tmp.path)])),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
if (!agent) throw new Error("expected configured Markdown agent")
expect(agent.system).toBe("Review carefully.")
return agent
})
}
function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
@@ -80,6 +80,33 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("inherits the provider websocket policy with model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
websocket: false,
models: { inherited: {}, override: { websocket: true } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
}),
}),
])
const inherited = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.websocket).toBe(false)
expect(override.websocket).toBe(true)
expect(untouched.websocket).toBeUndefined()
}),
)
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+1
View File
@@ -94,6 +94,7 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
websocket: true,
})
}),
)
@@ -267,8 +267,8 @@
"env": ["AWS_ACCESS_KEY_ID"],
"npm": "@ai-sdk/amazon-bedrock",
"models": {
"amazon.nova-2-lite-v1:0": {
"id": "amazon.nova-2-lite-v1:0",
"us.amazon.nova-2-lite-v1:0": {
"id": "us.amazon.nova-2-lite-v1:0",
"name": "Nova 2 Lite",
"release_date": "2026-01-01",
"attachment": false,
+1 -1
View File
@@ -1079,7 +1079,7 @@ describe("ModelsDevPlugin", () => {
const bedrock = yield* catalog.model.get(
Provider.ID.make("amazon-bedrock"),
Model.ID.make("amazon.nova-2-lite-v1:0"),
Model.ID.make("us.amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
@@ -4,8 +4,7 @@ import { Catalog } from "@opencode/core/catalog"
import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { AmazonBedrockPlugin, PROFILE_ONLY_BARE_IDS } from "@opencode/core/plugin/provider/amazon-bedrock"
import { Model } from "@opencode/core/model"
import { AmazonBedrockPlugin } from "@opencode/core/plugin/provider/amazon-bedrock"
import { Provider } from "@opencode/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -223,45 +222,4 @@ describe("AmazonBedrockPlugin", () => {
}),
),
)
it.effect("disables profile-only bare IDs while keeping working IDs", () =>
withEnv(noAmbientAWS, () =>
Effect.gen(function* () {
const catalog = yield* seedBedrock()
const controls = [
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"amazon.nova-micro-v1:0",
"openai.gpt-6-astra",
]
yield* catalog.transform((catalog) => {
for (const id of [...PROFILE_ONLY_BARE_IDS, ...controls]) {
catalog.model.update(Provider.ID.amazonBedrock, Model.ID.make(id), () => {})
}
})
yield* addPlugin()
for (const id of PROFILE_ONLY_BARE_IDS) {
expect(required(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).enabled).toBe(
false,
)
}
for (const id of controls) {
expect(required(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).enabled).toBe(
true,
)
}
}),
),
)
it.effect("does not create catalog entries for absent profile-only IDs", () =>
withEnv(noAmbientAWS, () =>
Effect.gen(function* () {
const catalog = yield* seedBedrock()
yield* addPlugin()
for (const id of PROFILE_ONLY_BARE_IDS) {
expect(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).toBeUndefined()
}
}),
),
)
})
@@ -164,11 +164,7 @@ describe("OpenAIPlugin", () => {
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
@@ -218,7 +214,7 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("selects Azure WebSocket from capability and the Azure flag only", () =>
it.effect("selects Azure WebSocket from capability unless the policy disables it", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
@@ -239,54 +235,44 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
kind: "primary",
scope: {
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agentID,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
)
const prepare = (websocket?: boolean) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
cost: [],
limit: { context: 200_000, output: 32_000 },
websocket,
})
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
kind: "primary",
scope: {
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agentID,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
)
const prepared = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const otherProvider = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const prepared = yield* prepare()
const disabled = yield* prepare(false)
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.http).toBeUndefined()
expect(otherProvider.options.webSocket).toBeUndefined()
expect(disabled.options.webSocket).toBeUndefined()
}),
)
})
+5 -5
View File
@@ -168,7 +168,7 @@ describe("toSessionError", () => {
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
})
test("retries transport failures only when delivery is absent or not sent", () => {
test("retries transport failures unless the provider accepted or rejected the request", () => {
const retryable = [
llm(new TransportError({ message: "http transport", transport: "http", operation: "request" })),
llm(
@@ -180,8 +180,6 @@ describe("toSessionError", () => {
phase: "connect",
}),
),
]
const ineligible = [
llm(
new TransportError({
message: "send uncertain",
@@ -191,6 +189,8 @@ describe("toSessionError", () => {
phase: "send",
}),
),
]
const ineligible = [
llm(
new TransportError({
message: "response interrupted",
@@ -212,8 +212,8 @@ describe("toSessionError", () => {
),
]
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false])
})
test("honors provider retry header overrides", () => {
@@ -526,6 +526,23 @@ describe("SessionModelTransport", () => {
)
})
test("times out a hanging connect and falls back to http", async () => {
const connector: WebSocketConnector = { open: () => Effect.never }
await runWithTestClock(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(transport.bind(session), exchange("slow")).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* Effect.yieldNow
yield* TestClock.adjust("10 seconds")
expect(yield* Fiber.join(running)).toEqual(["fallback:slow"])
}),
)
})
test("times out an idle accepted request and poisons its socket", async () => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
@@ -629,25 +646,31 @@ describe("SessionModelTransport", () => {
)
})
test("falls back once when connection setup fails before send", async () => {
test("falls back when connection setup fails and keeps the Session on HTTP", async () => {
let attempts = 0
let fallbacks = 0
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
const connector: WebSocketConnector = {
open: () =>
Effect.sync(() => attempts++).pipe(Effect.andThen(Effect.fail(error("upgrade rejected", "not-sent")))),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const result = yield* collect(
transport.bind(session),
exchange("first", {
const executor = transport.bind(session)
const item = (id: string) =>
exchange(id, {
fallback: () => {
fallbacks++
return Stream.make("http")
},
}),
)
expect(result).toEqual(["http"])
expect(fallbacks).toBe(1)
})
expect(yield* collect(executor, item("first"))).toEqual(["http"])
expect(yield* collect(executor, item("second"))).toEqual(["http"])
// One failed upgrade per Session, not one per step.
expect(attempts).toBe(1)
expect(fallbacks).toBe(2)
}),
)
})
+4 -2
View File
@@ -487,10 +487,12 @@ export interface UI {
readonly attention: boolean
readonly unread?: "activity" | "error"
}[]
/** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */
/** Opens a tab for a session without focusing it. Returns false when tabs are disabled. */
open(sessionID: string): boolean
/** Focuses an already-open tab and returns false when it is not open. */
/** Opens a tab when needed, then focuses it. Returns false when tabs are disabled. */
focus(sessionID: string): boolean
/** Moves an open tab to an index and returns false when it is not open. */
move(sessionID: string, index: number): boolean
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
+6
View File
@@ -42,6 +42,9 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
websocket: Schema.Boolean.pipe(optional).annotate({
description: "Use the provider's WebSocket transport for this model. Defaults to the provider policy.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -60,6 +63,9 @@ class Model extends Schema.Class<Model>("Config.Model")({
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
websocket: Schema.Boolean.pipe(optional).annotate({
description: "Use the provider's WebSocket transport when the route supports it. Defaults to true.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
+2
View File
@@ -107,6 +107,8 @@ export const Info = Schema.Struct({
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session WebSocket policy; omitted inherits the provider policy, which defaults to enabled. */
websocket: Schema.Boolean.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+2
View File
@@ -59,6 +59,8 @@ export const Info = Schema.Struct({
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session WebSocket policy for routes that support it; omitted means enabled. */
websocket: Schema.Boolean.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
+1 -1
View File
@@ -961,7 +961,7 @@ function App(props: { pair?: DialogPairCredentials }) {
{
name: "opencode.update",
title: "Update OpenCode",
slash: { name: "update" },
slash: { name: "update", aliases: ["upgrade"] },
run: () => updater.open?.("manual"),
category: "System",
},
@@ -395,6 +395,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
open(sessionID: string) {
if (!enabled()) return
const session = root(sessionID)
if (state().tabs.some((tab) => tab.sessionID === session)) return
cancelledTabs.delete(session)
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, { sessionID: session, title: title(session) })
})
},
promote(sessionID: string) {
if (!enabled()) return
const session = root(sessionID)
+8 -2
View File
@@ -206,15 +206,21 @@ export function createPluginContext(input: {
}),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
host.sessionTabs.open(sessionID)
return true
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
host.sessionTabs.select(sessionID)
return true
},
move(sessionID, index) {
if (!host.sessionTabs.enabled()) return false
const target = host.data.session.root(sessionID)
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
host.sessionTabs.move(target, index)
return true
},
close(sessionID) {
if (!host.sessionTabs.enabled()) return false
const target = sessionID ?? host.sessionTabs.current()
@@ -136,7 +136,7 @@ export function ShellTab(props: { sessionID: string }) {
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{shell.command}
{shell.command.split("\n", 1)[0]}
</text>
</box>
)
@@ -23,7 +23,7 @@ const sessions = {
"child-b": session("child-b", "Second", "parent"),
}
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev"), shell("sh-c", "python3 - <<'PY'\nimport json")]
async function renderComposer(
defaultTab: "subagents" | "shell",
@@ -191,6 +191,17 @@ test("disabled shell bindings have no component fallbacks", async () => {
}
})
test("shell list shows one line per command", async () => {
const composer = await renderComposer("shell", {})
try {
const frame = composer.app.captureCharFrame()
expect(frame).toContain("python3 - <<'PY'")
expect(frame).not.toContain("import json")
} finally {
composer.app.renderer.destroy()
}
})
test("configured composer bindings work with a focused textarea", async () => {
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
try {
@@ -265,6 +265,23 @@ test("loads VCS metadata for each persisted tab location", async () => {
}
})
test("opens a background tab without changing the current session", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().some((tab) => tab.sessionID === "first"))
setup.tabs.open("background")
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
expect(setup.tabs.current()).toBe("first")
expect(setup.tabs.isPreview("background")).toBe(false)
setup.tabs.move("background", 0)
await wait(() => setup.tabs.tabs()[0]?.sessionID === "background")
} finally {
await setup.destroy()
}
})
test("loads location metadata when an open session moves", async () => {
const destination = `${directory}/moved-worktree`
const setup = await renderSessionTabs("first")
@@ -375,13 +375,15 @@ context.ui.router.navigate({ type: "home" })
return unregister
```
Tabs can be listed, opened, focused, and closed when session tabs are enabled.
Tabs can be listed, opened, focused, moved, and closed when session tabs are enabled. `open` leaves focus unchanged;
`focus` opens the tab when needed.
```ts
if (context.ui.tabs.enabled()) {
context.ui.tabs.open(sessionID)
const tabs = context.ui.tabs.list()
context.ui.tabs.open(backgroundSessionID)
context.ui.tabs.focus(sessionID)
const tabs = context.ui.tabs.list()
context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
context.ui.tabs.close(sessionID)
context.ui.tabs.close()
}
+4 -1
View File
@@ -538,4 +538,7 @@ headers, and model variants.
}
```
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, and model configuration.
`websocket: false` on a provider or model keeps it on HTTP instead of the
session WebSocket; a model policy overrides the provider policy.
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration.
@@ -108,6 +108,33 @@ Your identity needs the **Cognitive Services OpenAI User** role for Azure OpenAI
role for other Foundry models. If a request fails because the token belongs to another tenant, sign in again with
`az login --tenant TENANT_ID`.
## WebSocket transport
OpenAI, Azure, and xAI Responses models keep one WebSocket connection open per session and send each step over it
instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was
added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same
connection.
The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot
be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a
provider keep it on HTTP so the hooks observe every request.
Set `websocket: false` on a provider or model to keep it on HTTP; a model policy overrides the provider policy:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"websocket": false,
"models": {
"gpt-5.5": { "websocket": true },
},
},
},
}
```
## Endpoint
Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,