mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 22:06:23 +00:00
Compare commits
10
Commits
web-headers
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a27842fe6 | ||
|
|
d797722187 | ||
|
|
7390832f13 | ||
|
|
7689c3654e | ||
|
|
7df0935ada | ||
|
|
bcd43760df | ||
|
|
9073c522ef | ||
|
|
04c296310e | ||
|
|
79d657b8fe | ||
|
|
606ec4fa38 |
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const empty: Info = {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
|
||||
: undefined
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
return parseRecord(text)
|
||||
})
|
||||
|
||||
const write = Effect.fnUntraced(function* (text: string) {
|
||||
@@ -61,6 +61,9 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const load = Effect.fnUntraced(function* (migration?: Info) {
|
||||
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
|
||||
})
|
||||
|
||||
const get = Effect.fn("cli.config.get")(() =>
|
||||
withLock(
|
||||
@@ -72,8 +75,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (migration?.cause)
|
||||
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
|
||||
if (migration?.info) return migration.info
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
return yield* load(migration?.info)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -83,7 +85,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const migration = yield* migrate
|
||||
if (migration?.cause) return yield* Effect.failCause(migration.cause)
|
||||
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const current = yield* load(migration?.info)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
@@ -102,7 +104,7 @@ export const layer = Layer.effect(
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
return merge(config, content)
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
@@ -113,6 +115,39 @@ export const layer = Layer.effect(
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||
|
||||
function merge(...values: readonly (Info | undefined)[]) {
|
||||
return Option.getOrElse(
|
||||
decode(
|
||||
values.reduce<Record<string, unknown>>(
|
||||
(result, value) => mergeRecords(result, value ?? {}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
() => empty,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeRecords(base: object, overlay: object) {
|
||||
return Object.entries(overlay).reduce<Record<string, unknown>>(
|
||||
(result, [key, value]) => {
|
||||
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
|
||||
return result
|
||||
},
|
||||
{ ...base },
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseRecord(text: string) {
|
||||
const errors: ParseError[] = []
|
||||
const value: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
}
|
||||
|
||||
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
|
||||
@@ -71,6 +71,51 @@ test("preserves the schema in an existing cli.json", async () => {
|
||||
expect(await Bun.file(file).json()).toEqual(config)
|
||||
})
|
||||
|
||||
test("merges inline CLI config content over the global config", async () => {
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tabs: { enabled: true, scope: "global" },
|
||||
keybinds: { "app.exit": "ctrl+q" },
|
||||
plugins: ["global"],
|
||||
animations: true,
|
||||
}),
|
||||
)
|
||||
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
|
||||
tabs: { enabled: false },
|
||||
keybinds: { "help.show": false },
|
||||
plugins: ["inline"],
|
||||
animations: false,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
const loaded = yield* service.get()
|
||||
const updated = yield* service.update((draft) => {
|
||||
draft.animations = true
|
||||
draft.mouse = false
|
||||
})
|
||||
return { loaded, updated }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
|
||||
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
|
||||
expect(result.loaded.plugins).toEqual(["inline"])
|
||||
expect(result.updated).toMatchObject({ animations: false, mouse: false })
|
||||
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(
|
||||
|
||||
@@ -4,6 +4,7 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
|
||||
return {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_CLI_CONFIG_CONTENT: undefined,
|
||||
OPENCODE_CONFIG_CONTENT: "{}",
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"),
|
||||
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||
|
||||
@@ -54,8 +54,12 @@ export type LocationGetInput = { readonly location?: { readonly directory?: stri
|
||||
export type LocationGetOutput = Location.PublicInfo
|
||||
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
|
||||
|
||||
export type LocationReloadOutput = void
|
||||
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
readonly reload: LocationReloadOperation<E>
|
||||
}
|
||||
|
||||
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ServerStatusOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
@@ -287,7 +288,13 @@ const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: Loca
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
|
||||
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
|
||||
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
|
||||
get: EndpointLocationGet(raw),
|
||||
reload: EndpointLocationReload(raw),
|
||||
})
|
||||
|
||||
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
|
||||
preserveEffect<AgentListOutput>()(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ServerStatusOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
@@ -416,6 +417,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reload: (requestOptions?: RequestOptions) =>
|
||||
request<LocationReloadOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/location/reload`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 503],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
agent: {
|
||||
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -820,6 +820,15 @@ export type SessionUsageRecorded = {
|
||||
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
|
||||
}
|
||||
|
||||
export type LocationShutdown = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "location.shutdown"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2321,6 +2330,7 @@ export type IntegrationInfo = {
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| LocationShutdown
|
||||
| ModelsDevRefreshed
|
||||
| CredentialUpdated
|
||||
| CredentialSwitched
|
||||
@@ -2429,14 +2439,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
@@ -2445,6 +2447,14 @@ export type ServiceUnavailableError = {
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2653,6 +2663,8 @@ export type LocationGetInput = {
|
||||
|
||||
export type LocationGetOutput = LocationPublicInfo
|
||||
|
||||
export type LocationReloadOutput = void
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -592,6 +592,12 @@ export function createData(config: CreateDataInput) {
|
||||
|
||||
function handleEvent(event: OpenCodeEvent) {
|
||||
switch (event.type) {
|
||||
case "location.shutdown": {
|
||||
if (!event.location) return
|
||||
result.location.invalidate(event.location)
|
||||
refresh(() => result.location.sync(event.location))
|
||||
return
|
||||
}
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
|
||||
@@ -182,7 +182,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [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.
|
||||
non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -472,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data).
|
||||
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
|
||||
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
|
||||
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
|
||||
left behind rather than replacing the error.
|
||||
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
|
||||
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
|
||||
step if `new X()` in a program is ever needed.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
|
||||
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
|
||||
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
@@ -146,9 +146,17 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const prototype = builtins[type]
|
||||
const construct = (args: Array<unknown>, newTarget: Callable) => {
|
||||
const proto = prototypeFrom(newTarget, prototype)
|
||||
return type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
|
||||
const options = args[type === "AggregateError" ? 2 : 1]
|
||||
if (!(options instanceof Obj) || !has(options, "cause")) return created
|
||||
return Effect.map(created, (value) => {
|
||||
define(value, "cause", get(options, "cause"), hidden)
|
||||
return value
|
||||
})
|
||||
}
|
||||
const ctor: Native<R> = constructor<R>(builtins, prototype, {
|
||||
name: type,
|
||||
|
||||
@@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
import { Throw, typeError } from "./model.js"
|
||||
import { PendingThrow, Throw, typeError } from "./model.js"
|
||||
import { fn } from "./native.js"
|
||||
import {
|
||||
Callable,
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
has,
|
||||
hidden,
|
||||
keys,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
@@ -60,14 +63,28 @@ export const extensionGlobals = <R>(
|
||||
) {
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
if (value instanceof ErrorObj) {
|
||||
const name = coerceToString(get(value, "name"))
|
||||
const message = get(value, "message")
|
||||
const text = message === undefined ? "" : coerceToString(message)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
const copied =
|
||||
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
for (const key of new Set(["cause", ...keys(value)])) {
|
||||
if (uncrossed.has(key) || !has(value, key)) continue
|
||||
const item = crossing(() => next(get(value, key)))
|
||||
if (item === left) continue
|
||||
Object.defineProperty(copied, key, {
|
||||
value: item,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: key !== "cause",
|
||||
})
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
const copied =
|
||||
value instanceof Arr
|
||||
? value.items.map(next)
|
||||
@@ -85,18 +102,29 @@ export const extensionGlobals = <R>(
|
||||
if (isPrimitive(value)) return value
|
||||
if (typeof value === "function") return wrap(value, label)
|
||||
if (value !== null && typeof value === "object") {
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
|
||||
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
|
||||
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
|
||||
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
|
||||
if (value instanceof Error) {
|
||||
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
|
||||
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
|
||||
seen.add(value)
|
||||
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
|
||||
const fields = value as unknown as Record<string, unknown>
|
||||
for (const key of new Set(["cause", ...Object.keys(value)])) {
|
||||
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
|
||||
const item = crossing(() => next(fields[key], `${label}.${key}`))
|
||||
if (item === left) continue
|
||||
define(copied, key, item, key === "cause" ? hidden : undefined)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
@@ -160,6 +188,22 @@ export const extensionGlobals = <R>(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
|
||||
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
|
||||
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
|
||||
*/
|
||||
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
|
||||
const left = Symbol("left behind")
|
||||
const crossing = (convert: () => unknown): unknown => {
|
||||
try {
|
||||
return convert()
|
||||
} catch (reason) {
|
||||
if (reason instanceof PendingThrow) return left
|
||||
throw reason
|
||||
}
|
||||
}
|
||||
|
||||
const hostErrors = new Map<string, ErrorConstructor>([
|
||||
["TypeError", TypeError],
|
||||
["RangeError", RangeError],
|
||||
|
||||
@@ -163,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(held[0]).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("a program Error crosses as a host Error with its name and message", async () => {
|
||||
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
|
||||
held.length = 0
|
||||
await value(`keep(new TypeError("bad"))`)
|
||||
expect(held[0]).toBeInstanceOf(TypeError)
|
||||
expect((held[0] as Error).message).toBe("bad")
|
||||
expect(Object.keys(held[0] as object)).toEqual([])
|
||||
held.length = 0
|
||||
await value(`
|
||||
const e = new Error("m", { cause: new RangeError("root") })
|
||||
e.code = "ENOENT"; e.detail = { path: "x" }
|
||||
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
|
||||
keep(e)`)
|
||||
const crossed = held[0] as Error & Record<string, unknown>
|
||||
expect(crossed.cause).toBeInstanceOf(RangeError)
|
||||
expect((crossed.cause as Error).message).toBe("root")
|
||||
expect(Object.keys(crossed)).toEqual(["code", "detail"])
|
||||
expect(crossed.detail).toEqual({ path: "x" })
|
||||
expect(crossed.stack).not.toBe("chosen")
|
||||
expect(String(crossed)).toBe("Error: m")
|
||||
expect(crossed.constructor).toBe(Error)
|
||||
})
|
||||
|
||||
test("an Error with an unknown name crosses as a plain Error", async () => {
|
||||
@@ -231,6 +245,49 @@ describe("host errors", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
|
||||
class Handle {}
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "fs",
|
||||
globals: {
|
||||
open: () => {
|
||||
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: "x",
|
||||
detail: { retried: true },
|
||||
handle: new Handle(),
|
||||
retry: () => 1,
|
||||
})
|
||||
throw new Error("open failed", { cause: error })
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(
|
||||
await value(
|
||||
`try { open() } catch (e) {
|
||||
const c = e.cause
|
||||
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
|
||||
}`,
|
||||
target,
|
||||
),
|
||||
).toEqual([
|
||||
"open failed",
|
||||
[],
|
||||
true,
|
||||
"ENOENT",
|
||||
-2,
|
||||
"x",
|
||||
{ retried: true },
|
||||
["code", "errno", "path", "detail"],
|
||||
false,
|
||||
])
|
||||
})
|
||||
|
||||
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
|
||||
const reason = { status: 404, nested: { a: 1 } }
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -350,6 +350,16 @@ describe("Error values and instanceof", () => {
|
||||
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
|
||||
})
|
||||
|
||||
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const inner = new Error("root")
|
||||
const e = new TypeError("m", { cause: inner })
|
||||
const agg = new AggregateError([], "a", { cause: 3 })
|
||||
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
|
||||
).toEqual([true, [], false, true, 3])
|
||||
})
|
||||
|
||||
test("thrown errors keep instanceof through try/catch", async () => {
|
||||
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
|
||||
true,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ListInput {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly close: Effect.Effect<void>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
|
||||
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
|
||||
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||
@@ -100,6 +101,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
let closed = false
|
||||
const forms = yield* Cache.makeWith<ID, Entry>(
|
||||
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
|
||||
{
|
||||
@@ -137,6 +139,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
yield* Cache.set(forms, id, entry)
|
||||
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
||||
if (closed) yield* cancel(id).pipe(Effect.orDie)
|
||||
return form
|
||||
}),
|
||||
),
|
||||
@@ -202,19 +205,21 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Cache.values(forms).pipe(
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(
|
||||
Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
||||
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
const close = Effect.sync(() => {
|
||||
closed = true
|
||||
}).pipe(
|
||||
Effect.andThen(Cache.values(forms)),
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(
|
||||
Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
||||
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => close)
|
||||
|
||||
return Service.of({ create, ask, get, list, state, reply, cancel })
|
||||
return Service.of({ create, ask, get, list, state, reply, cancel, close })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Model } from "./model.js"
|
||||
@@ -18,6 +18,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationLifecycle } from "./location-lifecycle.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
@@ -57,6 +58,7 @@ export { Service, node, type Interface } from "./instance/service.js"
|
||||
|
||||
const nodes = [
|
||||
Location.node,
|
||||
LocationLifecycle.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
@@ -157,6 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
|
||||
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
|
||||
// Instance boot failures are defects; provided operations retain their typed errors.
|
||||
Layer.orDie,
|
||||
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export * as LocationLifecycle from "./location-lifecycle.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { LocationEvent } from "@opencode/schema/location-event"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
|
||||
>()("@opencode/LocationLifecycle") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
let closed = false
|
||||
const shutdown = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
closed = true
|
||||
yield* permission.close
|
||||
yield* forms.close
|
||||
yield* rpc.close
|
||||
yield* bus.publish(
|
||||
LocationEvent.Shutdown,
|
||||
{},
|
||||
{
|
||||
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.uninterruptible),
|
||||
)
|
||||
return Service.of({
|
||||
isClosed: () => closed,
|
||||
shutdown,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Node } from "@opencode/util/effect/app-node"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
@@ -17,6 +17,24 @@ export class Service extends Context.Service<
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
|
||||
const locations = yield* Service
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
|
||||
discard: true,
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
// Boot every replacement now and let all builds settle even if one fails.
|
||||
const results = yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const failure = results.find(Exit.isFailure)
|
||||
if (failure) return yield* Effect.failCause(failure.cause)
|
||||
yield* Effect.logInfo("location services reloaded", { count: refs.length })
|
||||
})
|
||||
|
||||
/** Normalize equivalent placements before they become resource-cache keys. */
|
||||
export function canonical(ref: Location.Ref) {
|
||||
return Location.Ref.make({
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationLifecycle } from "./location-lifecycle.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
@@ -28,12 +28,17 @@ export function buildLocationServiceMap(
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const finish = Effect.suspend(() => {
|
||||
if (Exit.isSuccess(exit)) {
|
||||
return Effect.gen(function* () {
|
||||
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
|
||||
// A boot detached while in flight still needs shutdown and cancellation.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
|
||||
return yield* lifecycle.shutdown
|
||||
build.close = lifecycle.shutdown
|
||||
})
|
||||
}
|
||||
// An explicitly invalidated build must not evict its replacement.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
|
||||
if (Exit.isSuccess(exit)) {
|
||||
build.close = Context.get(exit.value, Rpc.Service).close
|
||||
return Effect.void
|
||||
}
|
||||
MutableHashMap.remove(builds, ref)
|
||||
// Evict once per failed build, before its result reaches borrowers.
|
||||
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
|
||||
@@ -61,10 +66,11 @@ export function buildLocationServiceMap(
|
||||
const key = LocationServiceMap.canonical(ref)
|
||||
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
|
||||
MutableHashMap.remove(builds, key)
|
||||
// Detach routing first, then end pending RPCs that still borrow the old graph.
|
||||
// Detach routing first, then cancel interactions and notify clients. Running
|
||||
// steps retain their borrowed graph until they can hand off at a boundary.
|
||||
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
|
||||
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
|
||||
}),
|
||||
}).pipe(Effect.uninterruptible),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
@@ -101,6 +101,7 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly close: Effect.Effect<void>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -127,18 +128,22 @@ const layer = Layer.effect(
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
let closed = false
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const close = Effect.gen(function* () {
|
||||
closed = true
|
||||
yield* Effect.forEach(Array.from(pending.values()), (item) =>
|
||||
bus
|
||||
.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
|
||||
)
|
||||
pending.clear()
|
||||
}).pipe(Effect.uninterruptible)
|
||||
yield* Effect.addFinalizer(() => close)
|
||||
|
||||
const savedRules = Effect.fnUntraced(function* () {
|
||||
return (yield* saved.list({ projectID: location.project.id })).map(
|
||||
@@ -201,6 +206,10 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
|
||||
const item = { request, agent, deferred }
|
||||
if (closed) {
|
||||
yield* Deferred.fail(deferred, new DeclinedError())
|
||||
return item
|
||||
}
|
||||
if (pending.has(request.id))
|
||||
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
|
||||
pending.set(request.id, item)
|
||||
@@ -212,6 +221,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
|
||||
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
|
||||
const result = yield* evaluateInput(input)
|
||||
const value = request(input, result.message)
|
||||
if (result.effect === "ask") yield* create(value, input.agent)
|
||||
@@ -220,6 +230,7 @@ const layer = Layer.effect(
|
||||
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return yield* Effect.die(new DeclinedError())
|
||||
const result = yield* evaluateInput(input)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -321,7 +332,7 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ ask, assert, reply, get, forSession, list, close })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -42,8 +42,9 @@ that section.
|
||||
CLI and TUI preferences are separate from OpenCode's server and project
|
||||
configuration. They live in the global `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
|
||||
project-local CLI configuration. Most preferences can also be changed from the
|
||||
TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
|
||||
inline JSON over the global settings. Most preferences can also be changed from
|
||||
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
|
||||
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It covers terminal-only settings such as themes,
|
||||
|
||||
@@ -57,6 +57,9 @@ export const Plugin = define({
|
||||
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
|
||||
if (session.parentID) return
|
||||
|
||||
const active = sessions.get(event.sessionID)
|
||||
const settings = yield* loadSettings()
|
||||
if (!settings) {
|
||||
|
||||
@@ -108,6 +108,7 @@ export const layer = Layer.effect(
|
||||
return yield* SessionRunner.DrainResult.$match(result, {
|
||||
Complete: () => Effect.void,
|
||||
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
|
||||
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
|
||||
})
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
|
||||
@@ -100,7 +100,6 @@ export const layer = (options?: Options) =>
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
suspended: ReadonlySet<SessionSchema.ID>,
|
||||
) {
|
||||
const state = background.status === "running" ? "cancelled" : background.status
|
||||
const text =
|
||||
@@ -124,7 +123,9 @@ export const layer = (options?: Options) =>
|
||||
state,
|
||||
text,
|
||||
}),
|
||||
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
|
||||
// Restart notices must not revive idle owners of long-lived shells.
|
||||
// Interrupted executions resume separately after their notices are admitted.
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
@@ -208,7 +209,7 @@ export const layer = (options?: Options) =>
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery, suspended)
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
}),
|
||||
{ discard: true },
|
||||
|
||||
@@ -320,15 +320,24 @@ export const layer = Layer.effect(
|
||||
// which transport actually carries the request, so both hook families are always offered.
|
||||
const webSocket =
|
||||
input.webSocket === "session" && model.transport === "websocket"
|
||||
? transport.bind(session.id, (connect) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
})
|
||||
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
|
||||
)
|
||||
? transport.bind(session.id, {
|
||||
handshake: (connect) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
})
|
||||
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
|
||||
send: (frame) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.send", { ...scope, frame })
|
||||
.pipe(Effect.map((event) => event.frame)),
|
||||
receive: (frame) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.receive", { ...scope, frame })
|
||||
.pipe(Effect.map((event) => event.frame)),
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,11 +59,18 @@ export interface Handshake {
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
|
||||
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
|
||||
*/
|
||||
export interface Interceptor {
|
||||
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
|
||||
readonly send?: (frame: string) => Effect.Effect<string>
|
||||
readonly receive?: (frame: string) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (
|
||||
sessionID: SessionSchema.ID,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
) => WebSocketChannelExecutor
|
||||
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
@@ -278,7 +285,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
input: WebSocketChannelExchange,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
interceptor?: Interceptor,
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -288,8 +295,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(input)
|
||||
const selected = handshake
|
||||
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
const selected = interceptor?.handshake
|
||||
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
: undefined
|
||||
const exchange: WebSocketChannelExchange = selected
|
||||
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
|
||||
@@ -354,6 +361,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const message = interceptor?.send
|
||||
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
|
||||
: create.message
|
||||
yield* Effect.logDebug("session websocket sending", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "send",
|
||||
@@ -364,7 +374,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
const sent = yield* channel.connection.sendText(message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
@@ -405,6 +415,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
@@ -482,10 +493,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return { frames, complete, http: channel.connection.http }
|
||||
})
|
||||
|
||||
const bind = (
|
||||
sessionID: SessionSchema.ID,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
): WebSocketChannelExecutor => ({
|
||||
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
@@ -495,7 +503,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, handshake)),
|
||||
Effect.andThen(start(owner, exchange, interceptor)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Continuation = { readonly step: number }
|
||||
export type DrainResult = Data.TaggedEnum<{
|
||||
Complete: {}
|
||||
Moved: { readonly continuation?: Continuation }
|
||||
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
|
||||
}>
|
||||
export const DrainResult = Data.taggedEnum<DrainResult>()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { LocationLifecycle } from "../../location-lifecycle.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
@@ -37,6 +38,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const lifecycle = yield* LocationLifecycle.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelTransport = yield* SessionModelTransport.Service
|
||||
@@ -69,6 +71,10 @@ const layer = Layer.effect(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
if (lifecycle.isClosed()) {
|
||||
yield* restore(modelTransport.close(sessionID))
|
||||
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
|
||||
}
|
||||
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
@@ -240,6 +246,7 @@ const layer = Layer.effect(
|
||||
webSocket: "session",
|
||||
})
|
||||
const outcome = yield* steps.attempt({
|
||||
isLocationClosed: lifecycle.isClosed,
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
@@ -356,6 +363,7 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
Bus.node,
|
||||
LocationLifecycle.node,
|
||||
llmClient,
|
||||
SessionContext.node,
|
||||
SessionModelTransport.node,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type Outcome = Data.TaggedEnum<{
|
||||
export const Outcome = Data.taggedEnum<Outcome>()
|
||||
|
||||
interface Input {
|
||||
readonly isLocationClosed: () => boolean
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -190,8 +191,9 @@ export const make = Effect.gen(function* () {
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
message: input.isLocationClosed()
|
||||
? "Interaction cancelled because the location shut down"
|
||||
: decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
@@ -251,7 +253,10 @@ export const make = Effect.gen(function* () {
|
||||
return Outcome.Continue({ error: llmError, decision: retry })
|
||||
|
||||
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.declines.length > 0) {
|
||||
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
|
||||
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
it.effect("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const data = {
|
||||
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
trace: { region: "test-region" },
|
||||
}
|
||||
const cause = apiCallError({ statusCode: 400, data })
|
||||
const error = yield* streamFailure(cause)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason.http?.status).toBe(400)
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect(error.reason.body).toBe(JSON.stringify(data))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, overrides)
|
||||
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
|
||||
|
||||
@@ -55,6 +55,7 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
|
||||
const permissionLayer = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
close: Effect.void,
|
||||
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
|
||||
assert: () => Effect.void,
|
||||
reply: () => Effect.void,
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
})
|
||||
|
||||
describe("SessionRestart background recovery", () => {
|
||||
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
|
||||
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
@@ -416,6 +416,20 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
|
||||
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
|
||||
|
||||
yield* seedInbox(database, parent, ["steer"])
|
||||
yield* seedInbox(database, child, ["steer"])
|
||||
yield* execution.wake(parent)
|
||||
yield* execution.wake(child)
|
||||
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([parent, child].toSorted())
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
|
||||
{
|
||||
@@ -509,7 +523,7 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
|
||||
|
||||
expect(drained).toEqual([sessionID])
|
||||
expect(drained).toEqual([])
|
||||
const inbox = yield* SessionInbox.list(database.db, sessionID)
|
||||
expect(inbox).toMatchObject([
|
||||
{
|
||||
@@ -561,7 +575,6 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the handshake hook", () =>
|
||||
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: string[] = []
|
||||
@@ -92,13 +92,31 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
delete event.headers["api-key"]
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.send", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`send:${event.kind}:${event.frame}`)
|
||||
event.frame = `${event.frame}+plugin`
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.receive", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`receive:${event.kind}:${event.frame}`)
|
||||
event.frame = event.frame.toUpperCase()
|
||||
}),
|
||||
)
|
||||
const bound: Array<{ url: string; headers: Record<string, string> }> = []
|
||||
const frames: string[] = []
|
||||
const websocketTransport = SessionModelTransport.Service.of({
|
||||
bind: (_sessionID, handshake) => ({
|
||||
bind: (_sessionID, interceptor) => ({
|
||||
execute: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!handshake) throw new Error("Expected a handshake interceptor")
|
||||
bound.push(yield* handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }))
|
||||
if (!interceptor?.handshake || !interceptor.send || !interceptor.receive)
|
||||
throw new Error("Expected a full WebSocket interceptor")
|
||||
bound.push(
|
||||
yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }),
|
||||
)
|
||||
frames.push(yield* interceptor.send("create"))
|
||||
frames.push(yield* interceptor.receive("created"))
|
||||
return { frames: Stream.empty, complete: Effect.void }
|
||||
}),
|
||||
}),
|
||||
@@ -127,7 +145,12 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
yield* prepared.options.webSocket!.execute({} as never)
|
||||
expect(bound).toEqual([{ url: "wss://example.test/v1/responses", headers: { authorization: "Bearer minted" } }])
|
||||
expect(seen).toEqual(["handshake:primary:wss://example.test/v1/responses"])
|
||||
expect(frames).toEqual(["create+plugin", "CREATED"])
|
||||
expect(seen).toEqual([
|
||||
"handshake:primary:wss://example.test/v1/responses",
|
||||
"send:primary:create",
|
||||
"receive:primary:created",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -178,12 +178,13 @@ describe("SessionModelTransport", () => {
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, (connect) =>
|
||||
Effect.succeed({
|
||||
url: connect.url,
|
||||
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
|
||||
}),
|
||||
)
|
||||
const executor = transport.bind(session, {
|
||||
handshake: (connect) =>
|
||||
Effect.succeed({
|
||||
url: connect.url,
|
||||
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
|
||||
}),
|
||||
})
|
||||
yield* collect(executor, exchange("first", { headers: { "api-key": "k" } }))
|
||||
yield* collect(executor, exchange("second", { headers: { "api-key": "k" } }))
|
||||
yield* collect(executor, exchange("third", { headers: { "api-key": "k" } }))
|
||||
@@ -196,6 +197,36 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("sends the frame the send tap returns and observes the frame the receive tap returns", async () => {
|
||||
const fixture = automatic()
|
||||
const seen: Array<{ tap: "send" | "receive"; frame: string }> = []
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, {
|
||||
send: (frame) => {
|
||||
seen.push({ tap: "send", frame })
|
||||
return Effect.succeed(`${frame}:rewritten`)
|
||||
},
|
||||
receive: (frame) => {
|
||||
seen.push({ tap: "receive", frame })
|
||||
return Effect.succeed(`${frame}:observed`)
|
||||
},
|
||||
})
|
||||
const frames = yield* collect(executor, exchange("first"))
|
||||
|
||||
// The wire carries the rewritten outbound frame; the driver sees the rewritten inbound frame.
|
||||
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:rewritten"]])
|
||||
expect(frames).toEqual(["completed:first:rewritten:observed"])
|
||||
expect(seen).toEqual([
|
||||
{ tap: "send", frame: "first" },
|
||||
{ tap: "receive", frame: "completed:first:rewritten" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not carry a checkpoint across physical connection rotation", async () => {
|
||||
const fixture = automatic()
|
||||
const checkpoints: Array<unknown> = []
|
||||
|
||||
@@ -100,6 +100,7 @@ for (const fixture of [
|
||||
)
|
||||
const result = yield* steps
|
||||
.attempt({
|
||||
isLocationClosed: () => false,
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
|
||||
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound frame about to be written to the Session's socket, after the provider driver has built
|
||||
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
|
||||
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
|
||||
*/
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
|
||||
* `frame` hands the replacement to the driver verbatim. Experimental.
|
||||
*/
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -120,6 +145,8 @@ export interface SessionHooks {
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound frame about to be written to the Session's socket, after the provider driver has built
|
||||
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
|
||||
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
|
||||
*/
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
|
||||
* `frame` hands the replacement to the driver verbatim. Experimental.
|
||||
*/
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -120,6 +145,8 @@ export interface SessionHooks {
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { PersistentPtyGroup } from "./groups/persistent-pty.js"
|
||||
import { ShellGroup } from "./groups/shell.js"
|
||||
import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
import { makeLocationGroup } from "./groups/location.js"
|
||||
import { IntegrationGroup } from "./groups/integration.js"
|
||||
import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
@@ -35,7 +35,6 @@ import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
|
||||
@@ -60,15 +59,17 @@ type SessionGroups<
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
> =
|
||||
| ReturnType<
|
||||
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
|
||||
>
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>>
|
||||
| typeof MessageGroup
|
||||
|
||||
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
|
||||
typeof makeFormGroup<LocationId, LocationService>
|
||||
>
|
||||
|
||||
type LocationGroup<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
|
||||
typeof makeLocationGroup<LocationId, LocationService>
|
||||
>
|
||||
|
||||
type MixedMiddlewareGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
@@ -93,6 +94,7 @@ type ApiGroups<
|
||||
| typeof PersistentPtyGroup
|
||||
| typeof CredentialGroup
|
||||
| LocationGroups<LocationId>
|
||||
| LocationGroup<LocationId, LocationService>
|
||||
| FormGroups<LocationId, LocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
|
||||
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
|
||||
@@ -152,7 +154,7 @@ const makeApiFromGroup = <
|
||||
> =>
|
||||
HttpApi.make("server")
|
||||
.add(ServerGroup)
|
||||
.add(LocationGroup.middleware(locationMiddleware))
|
||||
.add(makeLocationGroup(locationMiddleware))
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
.add(PluginGroup.middleware(locationMiddleware))
|
||||
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ServiceUnavailableError } from "../errors.js"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
@@ -25,19 +26,38 @@ export const locationQueryOpenApi = OpenApi.annotations({
|
||||
},
|
||||
})
|
||||
|
||||
export const LocationGroup = HttpApiGroup.make("server.location")
|
||||
.add(
|
||||
HttpApiEndpoint.get("location.get", "/api/location", {
|
||||
query: LocationQuery,
|
||||
success: Location.PublicInfo,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
// Middleware is applied per endpoint: reload acts on every loaded location and
|
||||
// must not boot the caller's location first.
|
||||
export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.location")
|
||||
.add(
|
||||
HttpApiEndpoint.get("location.get", "/api/location", {
|
||||
query: LocationQuery,
|
||||
success: Location.PublicInfo,
|
||||
})
|
||||
.middleware(locationMiddleware)
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "location.get",
|
||||
summary: "Get location",
|
||||
description: "Resolve the requested location or the server default location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("location.reload", "/api/location/reload", {
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ServiceUnavailableError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "location.get",
|
||||
summary: "Get location",
|
||||
description: "Resolve the requested location or the server default location.",
|
||||
identifier: "location.reload",
|
||||
summary: "Reload locations",
|
||||
description:
|
||||
"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "location" }))
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "location" }))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InstallationEvent } from "./installation-event.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { LegacyEventV1 } from "./legacy-event.js"
|
||||
import { LspEvent } from "./lsp-event.js"
|
||||
import { LocationEvent } from "./location-event.js"
|
||||
import { McpEvent } from "./mcp-event.js"
|
||||
import { Model } from "./model.js"
|
||||
import { ModelsDev } from "./models-dev.js"
|
||||
@@ -40,6 +41,7 @@ import { WebSearch } from "./websearch.js"
|
||||
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
|
||||
|
||||
const foundationDefinitions = Event.inventory(
|
||||
...LocationEvent.Definitions,
|
||||
...ModelsDev.Event.Definitions,
|
||||
...Credential.Event.Definitions,
|
||||
...Integration.Event.Definitions,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * as LocationEvent from "./location-event.js"
|
||||
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
|
||||
/** The location's cached services were shut down; clients must revalidate its reads. */
|
||||
export const Shutdown = ephemeral({ type: "location.shutdown", schema: {} })
|
||||
|
||||
export const Definitions = inventory(Shutdown)
|
||||
@@ -1,17 +1,33 @@
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Effect } from "effect"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
|
||||
handlers.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const location = yield* Location.Service
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return handlers
|
||||
.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const location = yield* Location.Service
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.handle("location.reload", () =>
|
||||
LocationServiceMap.reload().pipe(
|
||||
Effect.provideService(LocationServiceMap.Service, locations),
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.failCause(cause)
|
||||
: Effect.fail(new ServiceUnavailableError({ message: Cause.pretty(cause), service: "location" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1003,6 +1003,22 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "location.reload",
|
||||
title: "Reload locations",
|
||||
slash: { name: "reload" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
toast.show({ variant: "info", message: "Reloading all locations…", duration: 30000 })
|
||||
await client.api.location
|
||||
.reload()
|
||||
.then(() => {
|
||||
toast.show({ variant: "success", message: "Locations reloaded" })
|
||||
})
|
||||
.catch(toast.error)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.debug",
|
||||
title: "View debug info",
|
||||
|
||||
@@ -93,6 +93,8 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
|
||||
|
||||
const focusedError = createMemo(() => {
|
||||
const server = focusedServer()
|
||||
// Enter starts sign-in for auth-gated integrations instead of showing the auth reason
|
||||
if (server?.status.status === "needs_auth" && server.integrationID) return undefined
|
||||
return server ? statusError(server.status) : undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -1111,24 +1111,19 @@ export function Prompt(props: PromptProps) {
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
|
||||
) {
|
||||
if (!trimmed && (!props.sessionID || store.mode === "shell" || delivery === "queue"))
|
||||
return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||
const exitWord = trimmed === "exit" || trimmed === "quit" || trimmed === ":q"
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
if (delivery === "queue" && (store.mode === "shell" || exitWord || slash)) {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
if (exitWord) {
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
if (slash) {
|
||||
if (delivery === "queue") {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
clearPrompt()
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
@@ -1178,8 +1173,10 @@ export function Prompt(props: PromptProps) {
|
||||
// snapshot unless the user has started typing something new.
|
||||
const currentMode = store.mode
|
||||
const entry = { ...store.prompt, mode: currentMode }
|
||||
resetComposer()
|
||||
props.onSubmit?.()
|
||||
if (trimmed) {
|
||||
resetComposer()
|
||||
props.onSubmit?.()
|
||||
}
|
||||
const restoreEntry = () => {
|
||||
if (disposed || input.isDestroyed || input.plainText !== "") return
|
||||
input.setText(entry.text)
|
||||
@@ -1188,6 +1185,19 @@ export function Prompt(props: PromptProps) {
|
||||
restoreExtmarksFromPrompt(entry)
|
||||
input.cursorOffset = entry.text.length
|
||||
}
|
||||
const fail = (title: string, error: unknown) => {
|
||||
toast.show({ title, message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
}
|
||||
const attempt = async (title: string, run: () => Promise<unknown>) => {
|
||||
return run().then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
fail(title, error)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const variant = selection.variant
|
||||
let sessionID = props.sessionID
|
||||
@@ -1269,22 +1279,30 @@ export function Prompt(props: PromptProps) {
|
||||
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
|
||||
})
|
||||
}
|
||||
history.append(entry)
|
||||
const dispatch = (send: () => Promise<unknown>) => {
|
||||
const setup = newSession
|
||||
if (setup) void setup.gate.then(send).catch(setup.recover)
|
||||
else void send()
|
||||
const commitSelection = async () => {
|
||||
await prepareAgent()
|
||||
await commitModel()
|
||||
}
|
||||
if (!trimmed) {
|
||||
// Blank Enter in an existing session commits the composer's agent and
|
||||
// model selection, then hands off to the route (queued prompt promotion).
|
||||
await attempt("Failed to prepare session", async () => {
|
||||
await commitSelection()
|
||||
await props.onEmptySubmit?.()
|
||||
})
|
||||
return true
|
||||
}
|
||||
history.append(entry)
|
||||
if (currentMode === "shell") {
|
||||
move.startSubmit()
|
||||
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
|
||||
const send = () => client.api.session.shell({ sessionID: target, command: inputText })
|
||||
void (newSession ? newSession.gate.then(send).catch(newSession.recover) : send())
|
||||
setStore("mode", "normal")
|
||||
} else if (slashHead && isCommand) {
|
||||
const send = async () => {
|
||||
await prepareAgent()
|
||||
// Commands inherit the composer selection; command-specific overrides
|
||||
// remain server-owned and run after this preparation.
|
||||
await commitModel()
|
||||
await commitSelection()
|
||||
return client.api.session.command({
|
||||
sessionID: target,
|
||||
name: slashHead.name,
|
||||
@@ -1295,32 +1313,20 @@ export function Prompt(props: PromptProps) {
|
||||
delivery,
|
||||
})
|
||||
}
|
||||
const setup = newSession
|
||||
void (setup ? setup.gate.then(send) : send()).catch((error) => {
|
||||
if (setup) return setup.recover(error)
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
})
|
||||
void (newSession ? newSession.gate.then(send) : send()).catch((error) =>
|
||||
newSession ? newSession.recover(error) : fail("Failed to run command", error),
|
||||
)
|
||||
} else {
|
||||
move.startSubmit()
|
||||
try {
|
||||
await prepareAgent()
|
||||
} catch (error) {
|
||||
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return true
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await client.api.session.revert.commit({ sessionID: target }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!(await attempt("Failed to prepare session", prepareAgent))) return true
|
||||
// Revert must settle before optimistic admission: its committed echo
|
||||
// splices every local row at or after the boundary, which would include
|
||||
// a freshly admitted prompt.
|
||||
if (
|
||||
session?.revert &&
|
||||
!(await attempt("Failed to commit revert", () => client.api.session.revert.commit({ sessionID: target })))
|
||||
)
|
||||
return false
|
||||
if (pendingEditorSelection) {
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
const send = () =>
|
||||
@@ -1329,21 +1335,10 @@ export function Prompt(props: PromptProps) {
|
||||
text: formatEditorContext(pendingEditorSelection),
|
||||
resume: false,
|
||||
})
|
||||
if (newSession) {
|
||||
// Fold into the setup gate so the context still admits before the
|
||||
// user prompt once the session exists.
|
||||
newSession.gate = newSession.gate.then(send)
|
||||
} else {
|
||||
const error = await send().then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Fold into the setup gate so the context still admits before the
|
||||
// user prompt once the session exists.
|
||||
if (newSession) newSession.gate = newSession.gate.then(send)
|
||||
else if (!(await attempt("Failed to send editor context", send))) return false
|
||||
}
|
||||
// The data layer admits optimistically: the prompt renders immediately
|
||||
// and rolls back if the server rejects it, so submission does not wait
|
||||
@@ -1363,11 +1358,7 @@ export function Prompt(props: PromptProps) {
|
||||
// the server makes an unchanged selection a no-op.
|
||||
prepare: commitModel,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (newSession) return newSession.recover(error)
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
})
|
||||
.catch((error) => (newSession ? newSession.recover(error) : fail("Failed to send prompt", error)))
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ test.each(["enter", "space"])("starts OAuth with %s for an MCP server requiring
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Sign in required"))
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("enter to view error")
|
||||
if (key === "enter") fixture.app.mockInput.pressEnter()
|
||||
else fixture.app.mockInput.pressKey(" ")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Waiting for authorization"))
|
||||
@@ -79,7 +80,7 @@ async function renderMcp(options?: { failed?: boolean; location?: { directory: s
|
||||
name: "linear",
|
||||
status: options?.failed
|
||||
? { status: "failed", error: "MCP error -32000: Connection closed" }
|
||||
: { status: "needs_auth" },
|
||||
: { status: "needs_auth", error: "Authentication required" },
|
||||
integrationID: "mcp_linear",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1277,6 +1277,26 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
`experimental.ws.send` and `experimental.ws.receive` expose the frames themselves: `send` runs after the provider
|
||||
driver builds an outbound frame, `receive` runs on each inbound frame before the driver observes it. Whatever `frame` holds when the hook returns is what crosses the wire or reaches the driver;
|
||||
OpenCode does not validate it.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.session.hook(
|
||||
"experimental.ws.send",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(event.frame)
|
||||
if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
|
||||
event.frame = JSON.stringify(body)
|
||||
}),
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
|
||||
internally performs the next attempt.
|
||||
@@ -1317,6 +1337,9 @@ interface SessionHooks {
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -1410,7 +1410,31 @@ await ctx.session.hook(
|
||||
)
|
||||
```
|
||||
|
||||
This hook is experimental and its name or shape may change.
|
||||
`experimental.ws.send` and `experimental.ws.receive` expose the frames themselves, the WebSocket counterpart of
|
||||
editing an HTTP request or response body. `send` runs after the provider driver builds an outbound frame and before it
|
||||
is written; `receive` runs on each inbound frame before the driver observes it. Both carry the frame as a string and
|
||||
send whatever `frame` holds when the hook returns.
|
||||
|
||||
OpenCode does not validate rewritten frames. The driver tracks state from the provider's replies, so a rewrite that
|
||||
changes protocol meaning is the plugin's responsibility, just as a rewritten HTTP body is.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook(
|
||||
"experimental.ws.send",
|
||||
(event) => {
|
||||
const body = JSON.parse(event.frame)
|
||||
if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
|
||||
event.frame = JSON.stringify(body)
|
||||
},
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
|
||||
await ctx.session.hook("experimental.ws.receive", (event) => {
|
||||
if (event.frame.includes('"type":"error"')) console.error(event.frame)
|
||||
})
|
||||
```
|
||||
|
||||
These hooks are experimental and their names or shapes may change.
|
||||
|
||||
#### Retry policy
|
||||
|
||||
@@ -1458,6 +1482,8 @@ interface SessionHooks {
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
"experimental.ws.handshake": SessionWebSocketHandshakeHook
|
||||
"experimental.ws.send": SessionWebSocketSendHook
|
||||
"experimental.ws.receive": SessionWebSocketReceiveHook
|
||||
retry: SessionRetryHook
|
||||
}
|
||||
|
||||
@@ -1470,6 +1496,22 @@ interface SessionWebSocketHandshakeHook {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface SessionWebSocketSendHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
readonly kind: "primary" | "compaction" | "title" | "generate"
|
||||
frame: string
|
||||
}
|
||||
|
||||
interface SessionWebSocketReceiveHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
readonly kind: "primary" | "compaction" | "title" | "generate"
|
||||
frame: string
|
||||
}
|
||||
|
||||
type RetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
interface SessionRetryHook {
|
||||
|
||||
@@ -28,6 +28,17 @@ Add the published JSON Schema for editor validation and autocomplete:
|
||||
|
||||
OpenCode includes this automatically when it creates or migrates your CLI config.
|
||||
|
||||
## Inline config
|
||||
|
||||
Set `OPENCODE_CLI_CONFIG_CONTENT` to apply CLI settings from inline JSON:
|
||||
|
||||
```sh
|
||||
OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"enabled":false}}' opencode
|
||||
```
|
||||
|
||||
OpenCode merges the inline settings over the global `cli.json`. Nested objects are merged, while arrays and scalar
|
||||
values from the environment replace global values. Inline settings remain authoritative while the variable is set.
|
||||
|
||||
## Theme
|
||||
|
||||
Set the theme and color mode:
|
||||
|
||||
Reference in New Issue
Block a user