mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 18:36:22 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95503c1773 | ||
|
|
ba1448325a | ||
|
|
be2582f316 | ||
|
|
c0cb1c7a91 | ||
|
|
e10408b219 | ||
|
|
bdc143c1d5 | ||
|
|
d52380024d | ||
|
|
f1eed8bf11 | ||
|
|
4ea368e09e | ||
|
|
c6977a836f | ||
|
|
1dcc6551d9 | ||
|
|
9c8fb89979 | ||
|
|
51c926c3ac |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -206,7 +206,7 @@ function evaluateTemplate(
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", () => input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -173,6 +173,8 @@ const layer = Layer.effect(
|
||||
...(input.hidden ? ["--hidden"] : []),
|
||||
...(input.follow ? ["--follow"] : []),
|
||||
`--glob=${input.pattern}`,
|
||||
// Positive globs override rg's hidden-file filter; exclude before applying the result limit.
|
||||
...(input.hidden ? [] : ["--glob=!**/.*"]),
|
||||
"--glob=!**/.git/**",
|
||||
".",
|
||||
],
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,13 +217,20 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
}
|
||||
if (code.inline) {
|
||||
const fence = "`".repeat(Math.max(1, backticks + 1))
|
||||
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
|
||||
flushSpace()
|
||||
prefixQuote()
|
||||
const wrapper = encoder.encode(`${fence}${padding}${padding}${fence}`).byteLength
|
||||
appendRaw(
|
||||
`${fence}${padding}${sliceBytes(code.text, Math.max(0, CONTENT_BYTES - outputBytes - wrapper))}${padding}${fence}`,
|
||||
)
|
||||
const available = Math.max(0, CONTENT_BYTES - outputBytes - fence.length * 2)
|
||||
let payload = sliceBytes(code.text, available)
|
||||
while (payload) {
|
||||
const padding = /^[ `]|[ `]$/.test(payload) && !/^ +$/.test(payload) ? " " : ""
|
||||
const bytes = encoder.encode(payload).byteLength
|
||||
if (bytes + padding.length * 2 <= available) {
|
||||
appendRaw(`${fence}${padding}${payload}${padding}${fence}`)
|
||||
return
|
||||
}
|
||||
// Padding costs at most two bytes, so at most two whole-code-point trims are needed.
|
||||
payload = sliceBytes(payload, bytes - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeCell) {
|
||||
@@ -246,6 +253,8 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
// No amount of trimming can make the empty fenced block fit.
|
||||
if (!payload) return
|
||||
const excess = valueBytes - Math.max(0, CONTENT_BYTES - outputBytes)
|
||||
payload = sliceBytes(payload, Math.max(0, encoder.encode(payload).byteLength - Math.ceil(excess)))
|
||||
}
|
||||
|
||||
@@ -192,7 +192,9 @@ export const toModelContent = (path: string, offset: number | undefined, output:
|
||||
}
|
||||
|
||||
const start = output.type === "text-page" ? output.offset : 1
|
||||
const lines = output.content === "" ? [] : output.content.replace(/\n$/, "").split("\n")
|
||||
// Pages already join selected lines; a trailing newline represents a selected blank line.
|
||||
const text = output.type === "file" ? output.content.replace(/\n$/, "") : output.content
|
||||
const lines = output.content === "" ? [] : text.split("\n")
|
||||
const content = [
|
||||
lines.length === 0 ? `Read file ${path}, 0 lines` : `Read file ${path}, lines ${start}-${start + lines.length - 1}`,
|
||||
]
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -71,6 +71,73 @@ const it = testEffect(
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
for (const item of [
|
||||
...["$&", "$$", "$`", "$'"].flatMap((input) => [
|
||||
{ template: "Explain $ARGUMENTS.", input, expected: `Explain ${input}.` },
|
||||
{ template: "Explain $1.", input: `"${input}"`, expected: `Explain ${input}.` },
|
||||
{ template: "Explain.", input, expected: `Explain.\n\n${input}` },
|
||||
]),
|
||||
...["abc", "", "alpha beta", '"alpha beta"', "$1", "$<name>"].map((input) => ({
|
||||
template: "Explain $ARGUMENTS.",
|
||||
input,
|
||||
expected: `Explain ${input}.`,
|
||||
})),
|
||||
{
|
||||
template: "First $1. Rest $2.",
|
||||
input: '"alpha beta" gamma delta',
|
||||
expected: "First alpha beta. Rest gamma delta.",
|
||||
},
|
||||
{ template: "$ARGUMENTS / $ARGUMENTS", input: "$& $$", expected: "$& $$ / $& $$" },
|
||||
]) {
|
||||
it.live(`interpolates ${JSON.stringify(item.template)} with literal input ${JSON.stringify(item.input)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: { text: string; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die(new Error("unused command.list")),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { explain: { template: item.template } } }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
yield* command.execute({
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([{ text: item.expected, delivery: "queue" }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { writeSync } from "node:fs"
|
||||
import { convertHTMLToMarkdown } from "../../src/tool/html-markdown"
|
||||
|
||||
const html = await Bun.stdin.text()
|
||||
// Flush readiness before entering a conversion that may block the child event loop.
|
||||
writeSync(1, "ready\n")
|
||||
await Bun.write(Bun.stdout, convertHTMLToMarkdown(html))
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,13 +6,43 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Ripgrep } from "@opencode/core/ripgrep"
|
||||
import { RelativePath } from "@opencode/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)]))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
for (const hidden of [undefined, false, true]) {
|
||||
for (const limit of hidden ? [10] : [1, 10]) {
|
||||
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
|
||||
Bun.write(path.join(tmp.path, file), "needle\n"),
|
||||
),
|
||||
),
|
||||
)
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const files = yield* ripgrep.glob({
|
||||
cwd: tmp.path,
|
||||
pattern: "**/*.ts",
|
||||
limit,
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
|
||||
expect(files.map((item) => item.path).sort()).toEqual(
|
||||
(hidden ? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"] : ["src/visible.ts"]).map(
|
||||
(file) => RelativePath.make(file),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.live("globs files as an array", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { MAX_MARKDOWN_BYTES } from "../src/tool/html-markdown"
|
||||
|
||||
const budget = MAX_MARKDOWN_BYTES - 64 * 1024
|
||||
|
||||
test.each([
|
||||
["exhausted budget", budget, "x", "", false],
|
||||
["one byte short of an empty fence", budget - 13, "x", "", false],
|
||||
["small fitting block", 64, "x", "\n\n```\nx\n```", false],
|
||||
["last payload byte fits", budget - 15, "x", "\n\n```\nx\n```", false],
|
||||
["only an empty fence fits", budget - 14, "x", "\n\n```\n\n```", false],
|
||||
["Unicode payload truncates at a code point", budget - 18, "😀é", "\n\n```\n😀\n```", false],
|
||||
["quoted payload fits", budget - 23, "x", "\n\n> ```\n> x\n> ```", true],
|
||||
["only an empty quoted fence fits", budget - 22, "x", "\n\n> ```\n> \n> ```", true],
|
||||
["one byte short of an empty quoted fence", budget - 21, "x", "", true],
|
||||
] as const)(
|
||||
"finishes bounded code conversion: %s",
|
||||
async (_name, count, payload, suffix, quoted) => {
|
||||
const code = `<pre>${payload}</pre>`
|
||||
const html = `<p>${"x".repeat(count)}</p>${quoted ? `<blockquote>${code}</blockquote>` : code}`
|
||||
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
|
||||
const child = Bun.spawn({
|
||||
cmd: [process.execPath, fileURLToPath(new URL("./fixture/html-markdown.ts", import.meta.url))],
|
||||
stdin: new Blob([html]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
let ready = false
|
||||
let timeout: "startup" | "conversion" | undefined
|
||||
let timer = setTimeout(() => {
|
||||
timeout = "startup"
|
||||
child.kill("SIGKILL")
|
||||
}, 10_000)
|
||||
const stdout = (async () => {
|
||||
let output = ""
|
||||
for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) {
|
||||
output += chunk
|
||||
if (ready || !output.startsWith("ready\n")) continue
|
||||
ready = true
|
||||
clearTimeout(timer)
|
||||
// This watchdog runs outside the possibly stuck synchronous converter.
|
||||
timer = setTimeout(() => {
|
||||
timeout = "conversion"
|
||||
child.kill("SIGKILL")
|
||||
}, 3_000)
|
||||
}
|
||||
return output.slice("ready\n".length)
|
||||
})()
|
||||
const stderr = new Response(child.stderr).text()
|
||||
try {
|
||||
const exitCode = await child.exited
|
||||
const output = await stdout
|
||||
expect({ ready, timeout, exitCode, stderr: await stderr }).toEqual({
|
||||
ready: true,
|
||||
timeout: undefined,
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
})
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
|
||||
expect(output).toBe("x".repeat(Math.min(count, budget - 2)) + suffix)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
||||
await child.exited
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Environment } from "@opencode/core/environment/index"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { ReadTool } from "@opencode/core/tool/plugin/read"
|
||||
import { ReadToolFileSystem } from "@opencode/core/tool/read-filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -20,6 +21,96 @@ const fixture = Effect.gen(function* () {
|
||||
})
|
||||
const absolute = (value: string) => AbsolutePath.make(value)
|
||||
|
||||
describe("ReadTool text serialization", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "preserves a selected trailing blank line before continuation",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { offset: 1, limit: 2 },
|
||||
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: true, next: 3 },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: \n[Output truncated. Continue reading with offset: 3]",
|
||||
},
|
||||
{
|
||||
name: "preserves multiple selected trailing blank lines at a noninitial offset",
|
||||
content: "before\nalpha\n\n\nomega\n",
|
||||
page: { offset: 2, limit: 3 },
|
||||
output: { type: "text-page", content: "alpha\n\n", offset: 2, truncated: true, next: 5 },
|
||||
model: "Read file lines.txt, lines 2-4\n2: alpha\n3: \n4: \n[Output truncated. Continue reading with offset: 5]",
|
||||
},
|
||||
{
|
||||
name: "preserves a selected trailing blank line at EOF",
|
||||
content: "alpha\n\n",
|
||||
page: { limit: 2 },
|
||||
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
|
||||
},
|
||||
{
|
||||
name: "preserves internal blank lines in a page",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { limit: 3 },
|
||||
output: { type: "text-page", content: "alpha\n\nomega", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, lines 1-3\n1: alpha\n2: \n3: omega",
|
||||
},
|
||||
{
|
||||
name: "preserves continuation for a nonblank page",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { limit: 1 },
|
||||
output: { type: "text-page", content: "alpha", offset: 1, truncated: true, next: 2 },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha\n[Output truncated. Continue reading with offset: 2]",
|
||||
},
|
||||
{
|
||||
name: "strips only the terminal file newline in a whole-file read",
|
||||
content: "alpha\n\n",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha\n\n", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
|
||||
},
|
||||
{
|
||||
name: "does not add a line for a whole-file terminal newline",
|
||||
content: "alpha\n",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha\n", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha",
|
||||
},
|
||||
{
|
||||
name: "preserves a whole-file read without a terminal newline",
|
||||
content: "alpha",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha",
|
||||
},
|
||||
{
|
||||
name: "preserves empty whole-file output",
|
||||
content: "",
|
||||
page: {},
|
||||
output: { type: "file", content: "", encoding: "utf8" },
|
||||
model: "Read file lines.txt, 0 lines",
|
||||
},
|
||||
{
|
||||
name: "preserves empty-file page output",
|
||||
content: "",
|
||||
page: { limit: 2 },
|
||||
output: { type: "text-page", content: "", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, 0 lines",
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach((input) => {
|
||||
it.live(input.name, () =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* fixture
|
||||
const file = absolute(path.join(current.directory, "lines.txt"))
|
||||
yield* current.files.writeFileString(file, input.content)
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(current.environment, file, "lines.txt", input.page)
|
||||
|
||||
expect(result).toMatchObject(input.output)
|
||||
expect(ReadTool.toModelContent("lines.txt", undefined, result)).toBe(input.model)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadToolFileSystem", () => {
|
||||
it.effect("preserves the environment not-found error", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { GlobTool } from "@opencode/core/tool/plugin/glob"
|
||||
import { GrepTool } from "@opencode/core/tool/plugin/grep"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
@@ -67,6 +67,45 @@ const call = (name: "glob" | "grep", input: unknown) => ({
|
||||
})
|
||||
|
||||
describe("search tools", () => {
|
||||
for (const hidden of [undefined, false, true]) {
|
||||
for (const limit of hidden ? [10] : [1, 10]) {
|
||||
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
|
||||
Bun.write(path.join(tmp.path, file), "needle\n"),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* executeTool(
|
||||
registry,
|
||||
call("glob", { pattern: "**/*.ts", limit, ...(hidden === undefined ? {} : { hidden }) }),
|
||||
)
|
||||
const expected = hidden
|
||||
? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"]
|
||||
: ["src/visible.ts"]
|
||||
|
||||
expect(result.status).toBe("completed")
|
||||
expect(result.output).toHaveLength(expected.length)
|
||||
expect(result.output).toEqual(
|
||||
expect.arrayContaining(expected.map((file) => ({ path: path.normalize(file), type: "file" }))),
|
||||
)
|
||||
expect(result.metadata).toEqual({ count: expected.length, truncated: false })
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content?.[0]?.type === "text" ? result.content[0].text.split("\n").sort() : []).toEqual(
|
||||
expected.map((file) => path.join(tmp.path, file)).sort(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.live("bounds omitted glob and grep limits", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -90,6 +90,53 @@ describe("WebFetchTool helpers", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["`x`", "`` `x` ``"],
|
||||
["`x", "`` `x ``"],
|
||||
["x`", "`` x` ``"],
|
||||
["`", "`` ` ``"],
|
||||
["``", "``` `` ```"],
|
||||
["``x`", "``` ``x` ```"],
|
||||
["say(`x`)", "``say(`x`)``"],
|
||||
["a``b`c", "```a``b`c```"],
|
||||
["x", "`x`"],
|
||||
[" x ", "` x `"],
|
||||
[" x", "` x `"],
|
||||
["x ", "` x `"],
|
||||
[" ", "` `"],
|
||||
[" ` ", "`` ` ``"],
|
||||
])("preserves inline code boundaries for %j", (content, expected) => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>Use <code>${content}</code>.</p>`)).toBe(`Use ${expected}.`)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["discarded trailing backtick after ASCII", "x`", 7, "``x``"],
|
||||
["discarded trailing backtick after Unicode", "😀`", 10, "``😀``"],
|
||||
["discarded trailing backtick with spare room", "x`", 9, "``x``"],
|
||||
["retained trailing backtick", "x`", 10, "`` x` ``"],
|
||||
["new trailing backtick from an internal run", "x`y", 8, "``x``"],
|
||||
["leading backtick without padding room", "`x", 8, ""],
|
||||
["leading backtick alone fits", "`x", 9, "`` ` ``"],
|
||||
["leading backtick with payload fits", "`x", 10, "`` `x ``"],
|
||||
["all backticks truncated", "``", 11, "``` ` ```"],
|
||||
["all backticks fit", "``", 12, "``` `` ```"],
|
||||
["mixed internal runs truncated", "a``b`c", 11, "```a```"],
|
||||
["Unicode code point cannot fit", "😀`", 9, ""],
|
||||
["ordinary payload cannot fit", "x", 3, ""],
|
||||
["spaces cannot fit", " ", 4, ""],
|
||||
["space-only prefix fits", " ", 5, "` `"],
|
||||
["truncated prefix becomes space-only", " x", 6, "` `"],
|
||||
["discarded trailing space", "x ", 5, "`x`"],
|
||||
] as const)("fits inline code to its emitted boundaries: %s", (_name, content, spare, expected) => {
|
||||
const prefix = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024 - spare)
|
||||
const html = `<p>${prefix}<code>${content}</code></p>`
|
||||
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(output.slice(0, prefix.length)).toBe(prefix)
|
||||
expect(output.slice(prefix.length)).toBe(expected)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
})
|
||||
|
||||
test("keeps nested ordered and unordered lists structurally readable", () => {
|
||||
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user