Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline c23dde1c30 test(core): cover plugin permission precedence 2026-07-09 18:07:59 -05:00
Aiden Cline e4ef2acb0b fix(core): preserve agent permission precedence 2026-07-09 17:59:57 -05:00
141 changed files with 6269 additions and 6448 deletions
+2 -3
View File
@@ -27,7 +27,6 @@ Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
@@ -157,9 +156,9 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
+5 -5
View File
@@ -182,12 +182,12 @@ _Avoid_: Response envelope
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
@@ -209,13 +209,13 @@ _Avoid_: Response envelope
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
+2
View File
@@ -716,6 +716,8 @@
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
-1
View File
@@ -16,7 +16,6 @@
"dist"
],
"exports": {
".": "./src/promise/index.ts",
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./effect": "./src/effect/index.ts",
+3 -1
View File
@@ -550,7 +550,9 @@ export type Endpoint14_2Input = {
readonly id?: Endpoint14_2Request["payload"]["id"]
readonly title: Endpoint14_2Request["payload"]["title"]
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
readonly fields: Endpoint14_2Request["payload"]["fields"]
readonly mode: Endpoint14_2Request["payload"]["mode"]
readonly fields?: Endpoint14_2Request["payload"]["fields"]
readonly url?: Endpoint14_2Request["payload"]["url"]
}
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
+11 -2
View File
@@ -655,12 +655,21 @@ type Endpoint14_2Input = {
readonly id?: Endpoint14_2Request["payload"]["id"]
readonly title: Endpoint14_2Request["payload"]["title"]
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
readonly fields: Endpoint14_2Request["payload"]["fields"]
readonly mode: Endpoint14_2Request["payload"]["mode"]
readonly fields?: Endpoint14_2Request["payload"]["fields"]
readonly url?: Endpoint14_2Request["payload"]["url"]
}
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
payload: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -1056,7 +1056,14 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
body: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
successStatus: 200,
declaredStatuses: [404, 409, 400, 401],
empty: false,
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
const root = await bundleInputs("@opencode-ai/client/promise", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
+20 -24
View File
@@ -130,7 +130,6 @@ type Result = Success | Failure
interface Success {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
@@ -145,7 +144,7 @@ interface Failure {
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
### Tool-call hooks
@@ -258,11 +257,11 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
The limits are exactly three knobs:
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
| Limit | Default | Bounds |
| ---------------- | -------------------: | -------------------------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
@@ -280,11 +279,9 @@ const runtime = CodeMode.make({
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
@@ -292,19 +289,18 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
Failures are data:
| Kind | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
| Kind | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
+18 -30
View File
@@ -63,24 +63,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
### Tool execution
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested
functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection
is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result
wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers,
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
discarded. At most eight tool calls execute concurrently.
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
host-added framing are intentionally outside the budgets.
concurrency and data nesting depth.
### Data, files, and failures
@@ -90,7 +79,7 @@ boundary.
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
data, tool failures, limits, timeouts, execution failures, and warning truncation.
data, tool failures, limits, timeouts, and execution failures.
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
attach them to the outer result, but the program receives only the structured tool output.
@@ -106,8 +95,7 @@ CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
normally.
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
CodeMode namespaces instead of flattened model-facing names.
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
requests.
- Each nested call checks that its captured registration is still current before dispatching it.
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
authorization.
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
@@ -138,18 +126,18 @@ represent accurately rather than guessing semantics.
## Decisions and Rationale
| Decision | Rationale |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
| Decision | Rationale |
| --- | --- |
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
## Remaining Work
+6 -8
View File
@@ -111,15 +111,13 @@ ultimate source of truth.
- [x] `Promise.resolve` and `Promise.reject`.
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
values.
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
- [x] `Promise.all` preserves result order and rejects on the first observed failure.
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
combinator batches overlap as in normal JavaScript.
- [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
diagnostics.
- [x] `Promise.race` interrupts losing in-flight tool calls.
- [x] Un-awaited calls are drained before execution ends; unhandled failures become diagnostics.
- [x] `try`/`catch` can handle awaited tool and promise failures.
- [ ] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`. These calls currently settle
before returning, so separately constructed combinator batches do not overlap as normal JavaScript promises do.
- [ ] `Promise.any`.
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
- [ ] Custom promise construction with `new Promise(...)`.
@@ -271,7 +269,7 @@ ultimate source of truth.
These are actionable implementation items. Check them off only when behavior and direct tests land.
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
- [ ] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
+3 -12
View File
@@ -13,17 +13,11 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
/**
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
* waits for tool interruption cleanup. No default: absent means no timeout.
*/
/** Maximum wall-clock execution time in milliseconds. No default: absent means no timeout. */
readonly timeoutMs?: number
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
readonly maxToolCalls?: number
/**
* Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate
* budget of the same size. Fixed truncation notices and host formatting are additional.
*/
/** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */
readonly maxOutputBytes?: number
}
@@ -81,9 +75,8 @@ export const DiagnosticKind = Schema.Literals([
"TimeoutExceeded",
"ToolFailure",
"ExecutionFailure",
"Truncated",
])
/** Stable categories produced by program, schema, tool, limit, and truncation diagnostics. */
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind = typeof DiagnosticKind.Type
export const Diagnostic = Schema.Struct({
@@ -99,8 +92,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
export const Success = Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(ToolCallSchema),
+227 -287
View File
@@ -1,5 +1,5 @@
import { parse } from "acorn"
import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import {
copyIn,
@@ -219,7 +219,7 @@ const normalizeError = (error: unknown): Diagnostic => {
}
}
// Shared by catch bindings and Promise.allSettled rejection reasons.
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
const caughtErrorValue = (thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
@@ -611,80 +611,6 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
return out
}
// Promise work lives until the program returns, while observation only controls whether a
// settled rejection is reported. Neither extends execution: completion interrupts all work.
class PromiseRuntime<R> {
private readonly active = new Set<SandboxPromise>()
private readonly ids = new WeakMap<SandboxPromise, number>()
private readonly observed = new WeakSet<SandboxPromise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
return Effect.suspend(() => {
// Allocated at execution time (not construction) so re-run effects cannot share an id,
// and before the fork so diagnostics order by creation: a forked body that immediately
// creates promises of its own must sequence after its creator.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new SandboxPromise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
this.active.delete(promise)
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
this.ids.delete(promise)
return
}
const failure = normalizeError(Cause.squash(exit.cause))
this.failures.set(id, {
...failure,
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
})
})
return promise
})
})
}
// Synchronous on purpose: JS makes a promise "handled" the moment a construct takes
// responsibility for it (await, or membership in a combinator call), not when the
// consuming fiber later runs. Call sites must invoke this at that moment.
markObserved(promise: SandboxPromise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
// Pure settlement subscription: never re-runs work and never affects rejection reporting.
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
// Unobserved rejections that already settled, in creation order.
diagnostics(): Array<Diagnostic> {
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
}
// Normal-completion lifecycle: interrupts everything still running and reports the
// rejections that already settled un-awaited. interruptAll signals every fiber
// synchronously before awaiting termination, so no straggler can spawn new work between
// interrupts; the loop re-checks as a backstop because a straggler can create promises
// before its interrupt lands.
interrupt(): Effect.Effect<Array<Diagnostic>> {
const self = this
return Effect.gen(function* () {
while (self.active.size > 0) {
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
}
return self.diagnostics()
})
}
}
class Interpreter<R> {
private scopes: Array<Map<string, Binding>>
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -694,22 +620,24 @@ class Interpreter<R> {
private readonly logs: Array<string>
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
private readonly callPermits: Semaphore.Semaphore
private readonly promises: PromiseRuntime<R>
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
// program completion drains these (like a runtime waiting on in-flight work at exit) and
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
private readonly pendingSettlements: Set<SandboxPromise>
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
logs: Array<string> = [],
callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY),
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
) {
const globalScope = new Map<string, Binding>()
this.scopes = [globalScope]
this.invokeTool = invokeTool
this.toolKeys = toolKeys
this.logs = logs
this.callPermits = callPermits
this.promises = promises
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
@@ -775,12 +703,36 @@ class Interpreter<R> {
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
// without an explicit await, exactly as in JS.
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
yield* self.drainPendingSettlements()
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
}
// Eagerly starts a tool call in the execution's promise scope (so timeout and teardown
// interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
// their work completes before the execution ends - mirroring a JS runtime waiting on
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
const self = this
return Effect.gen(function* () {
while (self.pendingSettlements.size > 0) {
const promise = self.pendingSettlements.values().next().value
if (promise === undefined) break
const exit = yield* self.observePromise(promise)
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
const failure = normalizeError(Cause.squash(exit.cause))
throw new InterpreterRuntimeError(
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
undefined,
failure.kind,
["Await promises so failures can be caught and handled."],
)
}
})
}
// Eagerly starts a tool call on a supervised child fiber (so the execution timeout and
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
private createToolCallPromise(
@@ -791,20 +743,46 @@ class Interpreter<R> {
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
return this.promises.create(effect)
return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => {
const promise = new SandboxPromise(fiber)
this.pendingSettlements.add(promise)
return promise
})
}
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
// Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
// Promise.all([p, p])) never re-runs the underlying call.
private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
this.pendingSettlements.delete(promise)
return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
}
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
// observes it exactly like a synchronous throw at the await site. Settlement is idempotent
// (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call.
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
return Effect.flatMap(promises.await(promise), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
// observes it exactly like a synchronous throw at the await site.
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
const self = this
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
}
private unwrapPromiseExit(
promise: SandboxPromise | undefined,
exit: Exit.Exit<unknown, unknown>,
node?: AstNode,
): Effect.Effect<unknown, unknown> {
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
// A call Promise.race interrupted after losing settles as a catchable program failure;
// any other interruption is execution teardown (timeout/host) and must keep propagating
// as interruption rather than becoming program-visible data.
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
return Effect.fail(
new InterpreterRuntimeError(
"This tool call was interrupted because another value settled a Promise.race first.",
node,
),
)
})
}
return Effect.failCause(exit.cause)
}
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
@@ -1554,7 +1532,7 @@ class Interpreter<R> {
// matching real JS semantics for non-thenables.
const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value),
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
)
}
case "NewExpression":
@@ -2221,116 +2199,145 @@ class Interpreter<R> {
// Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable
// collection) mixing promise values and plain data - built inline, beforehand, via spread,
// whatever - because tool calls already run eagerly on their own fibers. Each combinator
// returns a real promise whose join runs on its own scope-owned fiber, observing member
// settlements; the concurrency cap stays where the work is: the fork semaphore.
// whatever - because tool calls already run eagerly on their own fibers; the combinators
// only observe settlements. Joining is therefore sequential (no extra fibers) without
// costing parallelism, and the concurrency cap stays where the work is: the fork semaphore.
private invokePromiseMethod(
ref: PromiseMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const self = this
if (ref.name === "resolve") {
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
// promise already fulfilled with the value. Pre-settled values still fork a scope-owned
// fiber so every promise shares one lifecycle (an abandoned reject is reported, teardown
// is uniform).
// promise already fulfilled with the value.
const value = args[0]
return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value))
return Effect.succeed(
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
)
}
if (ref.name === "reject") {
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
}
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
if (items === undefined) {
return this.createPromise(
Effect.fail(
new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
),
),
throw new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
)
}
// JS makes combinator members "handled" synchronously at the call - their rejections
// belong to the aggregate from this moment, even ones settling before it runs.
for (const item of items) {
if (item instanceof SandboxPromise) this.promises.markObserved(item)
}
switch (ref.name) {
case "all": {
// Each observation re-raises its member's failure, so Effect.all rejects on the first
// failure without waiting for the rest and preserves input order when all fulfill.
// Its failure-time interruption only unsubscribes the sibling waiters: the underlying
// fibers stay execution-owned and keep running, as in JS.
const observations = items.map((item) =>
// Mark every promise element observed up-front (Promise.all handles all of its
// members' failures, as in JS), race their settlements for fail-fast rejection, and
// preserve input order when they all fulfill. Rejected calls keep draining siblings.
const observations = items.map((item, index) =>
item instanceof SandboxPromise
? Effect.flatMap(this.promises.await(item), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
)
: Effect.succeed(item),
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
)
return this.createPromise(Effect.all(observations, { concurrency: "unbounded" }))
return Effect.gen(function* () {
const remaining = [...observations]
const values: Array<unknown> = []
values.length = items.length
while (remaining.length > 0) {
const winner = yield* Effect.raceAll(remaining)
const position = remaining.indexOf(observations[winner.index])
if (position >= 0) remaining.splice(position, 1)
if (Exit.isSuccess(winner.exit)) {
values[winner.index] = winner.exit.value
continue
}
yield* self.createPromise(
Effect.asVoid(
Effect.forEach(
items,
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
{ concurrency: "unbounded" },
),
),
)
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
}
return values
})
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
item instanceof SandboxPromise
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
)
return this.createPromise(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
return Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const { exit, promise } = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
return outcomes
}),
)
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
const thrown = raceInterrupted
? new InterpreterRuntimeError(
"This tool call was interrupted because another value settled a Promise.race first.",
node,
)
: Cause.squash(exit.cause)
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(thrown),
}),
)
}
return outcomes
})
}
case "race": {
if (items.length === 0) {
return this.createPromise(
Effect.fail(
new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
),
),
throw new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
)
}
const observations = items.map((item) =>
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
)
// First settlement (fulfilled OR rejected) wins; losing work stays execution-owned
// and is interrupted at normal completion (already observed) or by teardown.
return this.createPromise(
Effect.flatMap(Effect.raceAll(observations), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
),
const observations = items.map((item, index) =>
item instanceof SandboxPromise
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
)
return Effect.gen(function* () {
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
// racing them yields exactly that. Losing in-flight calls are then interrupted.
const winner = yield* Effect.raceAll(observations)
for (const [index, item] of items.entries()) {
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
item.interrupted = true
yield* Fiber.interrupt(item.fiber)
}
const winningItem = items[winner.index]
return yield* self.unwrapPromiseExit(
winningItem instanceof SandboxPromise ? winningItem : undefined,
winner.exit,
node,
)
})
}
}
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, {
callPermits: this.callPermits,
pendingSettlements: this.pendingSettlements,
})
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
const run = Effect.gen(function* () {
// Seed every parameter name into the scope as a TDZ slot first, so a default that
@@ -3477,109 +3484,68 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<Result, never, Services<Tools>> => {
const hooks = {
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
}
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
limits.maxToolCalls,
searchIndex,
hooks,
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
if (options.code.trim().length === 0) {
return Effect.succeed({
ok: false,
error: { kind: "ParseError", message: "Code cannot be empty." },
toolCalls: [],
toolCalls: tools.calls,
})
}
// Suspended so all per-execution state - tool-call admission budget and audit list, logs,
// and the timeout path's completed value - binds at run time: a reused Effect must start
// from a clean slate instead of observing a previous run's state.
return Effect.suspend(() => {
const hooks = {
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
}
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
limits.maxToolCalls,
searchIndex,
hooks,
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
// Set once the program body returned and its value crossed the data boundary, so a timeout
// firing during leftover interruption reports "completed with interrupted background work"
// instead of discarding the computed value as a plain timeout.
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
const base = Effect.acquireUseRelease(
Scope.make("parallel"),
(scope) =>
Effect.gen(function* () {
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Tools>>(scope)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
const value = yield* interpreter.run(program)
// Validate the result first so an invalid value is a fatal completion that closes
// the promise scope directly instead of taking the normal-completion path.
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
return {
ok: true,
value: result,
...(warnings.length > 0 ? { warnings } : {}),
const operation = Effect.gen(function* () {
const program = parseProgram(options.code)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
return {
ok: true,
value: result,
...logged(),
toolCalls: tools.calls,
} satisfies Result
}).pipe((program) => {
const timeoutMs = limits.timeoutMs
if (timeoutMs === undefined) return program
return program.pipe(
Effect.timeoutOrElse({
duration: timeoutMs,
orElse: () =>
Effect.succeed({
ok: false,
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
...logged(),
toolCalls: tools.calls,
} satisfies Result
}),
(scope, exit) => Scope.close(scope, exit),
)
const timeoutMs = limits.timeoutMs
const operation =
timeoutMs === undefined
? base
: base.pipe(
Effect.timeoutOrElse({
duration: timeoutMs,
orElse: () =>
Effect.sync(() => {
if (returned === undefined) {
return {
ok: false,
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
...logged(),
toolCalls: tools.calls,
} satisfies Result
}
// The timeout warning leads so byte-budget truncation cuts it last.
return {
ok: true,
value: returned.value,
warnings: [
{
kind: "TimeoutExceeded",
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
},
...returned.promises.diagnostics(),
],
...logged(),
toolCalls: tools.calls,
} satisfies Result
}),
}),
)
return operation.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.succeed({
ok: false,
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies Result),
),
Effect.map((result) =>
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
),
} satisfies Result),
}),
)
})
return operation.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.succeed({
ok: false,
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies Result),
),
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
)
}
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
@@ -3594,10 +3560,9 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
}
/**
* Bounds retained program payload bytes (serialized result value and logs) to `maxOutputBytes`.
* Warning diagnostics are bounded by a separate budget of the same size so a large value can
* never starve runtime-authored diagnostics. Fixed truncation notices are added outside those
* budgets, as is any framing added when a host renders the structured result. Truncation never
* Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
* Oversized values are replaced by their truncated serialized text with an explanatory marker,
* and logs are kept from the start until the remaining budget is exhausted. Truncation never
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
*/
@@ -3619,23 +3584,6 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
}
}
const warnings = result.ok ? (result.warnings ?? []) : []
const keptWarnings: Array<Diagnostic> = []
let warningBytes = 0
for (const warning of warnings) {
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
if (warningBytes + bytes > maxOutputBytes) break
warningBytes += bytes
keptWarnings.push(warning)
}
if (keptWarnings.length < warnings.length) {
truncated = true
keptWarnings.push({
kind: "Truncated",
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
})
}
const logs = result.logs ?? []
const kept: Array<string> = []
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
@@ -3652,16 +3600,8 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
}
if (!truncated) return result
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
const logsPart = kept.length > 0 ? { logs: kept } : {}
return result.ok
? {
ok: true,
value,
...warningsPart,
...logsPart,
truncated: true,
toolCalls: result.toolCalls,
}
? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
}
-1
View File
@@ -602,7 +602,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
+6 -2
View File
@@ -1,7 +1,11 @@
import type { Fiber } from "effect"
import type { Effect, Fiber } from "effect"
export class SandboxPromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
interrupted = false
constructor(
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
readonly immediate?: Effect.Effect<unknown, unknown>,
) {}
}
export class SandboxDate {
-20
View File
@@ -506,26 +506,6 @@ describe("CodeMode public contract", () => {
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
})
test("a reused execution Effect starts from a clean slate", async () => {
const echo = Tool.make({
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
code: `console.log("hi"); return await tools.host.echo({})`,
limits: { maxToolCalls: 1 },
})
const first = await Effect.runPromise(effect)
const second = await Effect.runPromise(effect)
// Per-execution state (tool-call budget and audit list, logs, timeout bookkeeping) must
// bind at run time, so the second run neither exhausts the budget nor leaks run 1's logs.
expect(first).toStrictEqual(second)
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([
@@ -1,906 +0,0 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75.
* Every test names its upstream source; test.failing cases are executable conformance
* targets for intended Promise behavior that CodeMode does not implement yet.
*
* Copyright 2014 Cubane Canada, Inc. All rights reserved.
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2016 Microsoft, Inc. All rights reserved.
* Copyright 2017 Caitlin Potter. All rights reserved.
* Copyright (C) 2016-2020 the V8 project authors. All rights reserved.
* Copyright (C) 2018-2020 Rick Waldron. All rights reserved.
* Copyright (C) 2019 Leo Balter. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) =>
Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
const value = async (code: string) => {
const result = await execute(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("Test262 Promise statics", () => {
test("statics are callable and return promises", async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A1.1_T1.js
// test/built-ins/Promise/allSettled/is-function.js
// test/built-ins/Promise/allSettled/returns-promise.js
// test/built-ins/Promise/race/S25.4.4.3_A1.1_T1.js
// test/built-ins/Promise/resolve/S25.4.4.5_A1.1_T1.js
// test/built-ins/Promise/reject/S25.4.4.4_A1.1_T1.js
expect(
await value(`
const values = [
Promise.all([]),
Promise.allSettled([]),
Promise.race([undefined]),
Promise.resolve(),
Promise.reject(),
]
const callable = [
typeof Promise.all,
typeof Promise.allSettled,
typeof Promise.race,
typeof Promise.resolve,
typeof Promise.reject,
]
try { await values[4] } catch {}
return [callable, values.map((item) => item instanceof Promise)]
`),
).toEqual([
["function", "function", "function", "function", "function"],
[true, true, true, true, true],
])
})
test("Promise.all returns fresh arrays for empty and settled inputs", async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A2.1_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T2.js
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T3.js
// test/built-ins/Promise/all/S25.4.4.1_A7.1_T1.js
expect(
await value(`
const input = []
const emptyPromise = Promise.all(input)
const empty = await emptyPromise
const onePromise = Promise.all([Promise.resolve(3)])
const one = await onePromise
return [
emptyPromise instanceof Promise,
empty instanceof Array,
empty.length,
empty !== input,
onePromise instanceof Promise,
one instanceof Array,
one.length,
one[0],
]
`),
).toEqual([true, true, 0, true, true, true, 1, 3])
})
test("Promise.all adopts values and preserves input order and identity", async () => {
// Sources:
// test/built-ins/Promise/all/resolve-non-thenable.js
// test/built-ins/Promise/all/S25.4.4.1_A8.2_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A8.2_T2.js
const result = await value(`
const first = { id: 1 }
const second = { id: 2 }
const values = await Promise.all([Promise.resolve(3), first, Promise.resolve(second)])
const observe = async (promise) => {
try { await promise; return "fulfilled" } catch (reason) { return reason }
}
return [
values.length,
values[0],
values[1] === first,
values[2] === second,
await observe(Promise.all([Promise.reject(1), Promise.resolve(2)])),
await observe(Promise.all([Promise.resolve(1), Promise.reject(2)])),
]
`)
expect(result).toEqual([3, 3, true, true, 1, 2])
})
test("Promise.allSettled returns fresh arrays and ordered outcome records", async () => {
// Sources:
// test/built-ins/Promise/allSettled/resolves-empty-array.js
// test/built-ins/Promise/allSettled/resolves-to-array.js
// test/built-ins/Promise/allSettled/resolved-all-fulfilled.js
// test/built-ins/Promise/allSettled/resolved-all-rejected.js
// test/built-ins/Promise/allSettled/resolved-all-mixed.js
// test/built-ins/Promise/allSettled/resolve-non-thenable.js
expect(
await value(`
const input = []
const empty = await Promise.allSettled(input)
const reason = { id: 4 }
const object = { id: 5 }
const outcomes = await Promise.allSettled([
Promise.resolve(1),
Promise.reject(2),
3,
Promise.reject(reason),
object,
])
return [
empty instanceof Array,
empty.length,
empty !== input,
outcomes,
outcomes[4].value === object,
outcomes.map((item) => Object.keys(item)),
]
`),
).toEqual([
true,
0,
true,
[
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
{ status: "fulfilled", value: 3 },
{ status: "rejected", reason: { id: 4 } },
{ status: "fulfilled", value: { id: 5 } },
],
true,
[
["status", "value"],
["status", "reason"],
["status", "value"],
["status", "reason"],
["status", "value"],
],
])
})
test("Promise.race preserves fulfillment, rejection, and iterable order", async () => {
// Sources:
// test/built-ins/Promise/race/S25.4.4.3_A6.2_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A7.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A7.2_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A7.3_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A7.3_T2.js
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return await Promise.all([
observe(Promise.race([23])),
observe(Promise.race([Promise.reject(7)])),
observe(Promise.race([Promise.resolve(1), Promise.resolve(2)])),
observe(Promise.race([Promise.reject(3), Promise.resolve(4)])),
])
`),
).toEqual([
["fulfilled", 23],
["rejected", 7],
["fulfilled", 1],
["rejected", 3],
])
})
test("combinators consume supported string iterables", async () => {
// Sources:
// test/built-ins/Promise/all/iter-arg-is-string-resolve.js
// test/built-ins/Promise/allSettled/iter-arg-is-string-resolve.js
// test/built-ins/Promise/race/iter-arg-is-string-resolve.js
expect(
await value(`
return [
await Promise.all("abc"),
await Promise.allSettled("ab"),
await Promise.race("abc"),
]
`),
).toEqual([
["a", "b", "c"],
[
{ status: "fulfilled", value: "a" },
{ status: "fulfilled", value: "b" },
],
"a",
])
})
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
// Sources:
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
// test/built-ins/Promise/resolve/resolve-non-obj.js
// test/built-ins/Promise/resolve/resolve-non-thenable.js
expect(
await value(`
const object = { id: 1 }
const promise = Promise.resolve(1)
return [
await Promise.resolve(23),
await Promise.resolve(Promise.resolve(24)),
(await Promise.resolve(object)) === object,
[promise].includes(Promise.resolve(promise)),
]
`),
).toEqual([23, 24, true, true])
})
test("Promise.reject preserves primitive and object reasons", async () => {
// Sources:
// test/built-ins/Promise/reject/S25.4.4.4_A2.1_T1.js
const result = await value(`
const object = { reason: true }
const reasons = [undefined, null, false, true, 0, "", 42, object]
const observe = async (reason) => {
try { await Promise.reject(reason); return false } catch (caught) { return caught === reason }
}
return await Promise.all(reasons.map(observe))
`)
expect(result).toEqual([true, true, true, true, true, true, true, true])
})
test("Promise.all resolves duplicate members into every slot", async () => {
// Sources:
// test/built-ins/Promise/all/invoke-resolve-on-promises-every-iteration-of-promise.js
// test/built-ins/Promise/all/invoke-resolve-on-values-every-iteration-of-promise.js
// (adapted: CodeMode has no observable Promise.resolve hook, so per-iteration
// handling of a repeated member is asserted through the resolved slots)
expect(
await value(`
const settled = Promise.resolve(3)
const computed = (async () => "computed")()
return [
await Promise.all([settled, settled, settled]),
await Promise.all([computed, "plain", computed]),
]
`),
).toEqual([
[3, 3, 3],
["computed", "plain", "computed"],
])
})
test("Promise.allSettled records duplicate members independently", async () => {
// Source: test/built-ins/Promise/allSettled/invoke-resolve-on-promises-every-iteration-of-promise.js
// (adapted: per-iteration handling of a repeated member is asserted through the
// outcome records instead of a Promise.resolve hook)
expect(
await value(`
const good = Promise.resolve(1)
const bad = Promise.reject(2)
return await Promise.allSettled([good, bad, good, bad])
`),
).toEqual([
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
])
})
test("combinators adopt members that settled before the call", async () => {
// Sources:
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/allSettled/reject-immed.js
// test/built-ins/Promise/race/reject-immed.js
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
// and were even observed, before the combinator call)
expect(
await value(`
const fulfilled = Promise.resolve("done")
const rejected = Promise.reject("failed")
try { await rejected } catch {}
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return [
await observe(Promise.all([fulfilled, rejected])),
await Promise.allSettled([rejected, fulfilled]),
await observe(Promise.race([rejected, fulfilled])),
]
`),
).toEqual([
["rejected", "failed"],
[
{ status: "rejected", reason: "failed" },
{ status: "fulfilled", value: "done" },
],
["rejected", "failed"],
])
})
test("combinator results follow input order, not settlement order", async () => {
// Sources:
// test/built-ins/Promise/all/resolve-non-thenable.js
// test/built-ins/Promise/allSettled/resolved-all-mixed.js
// (adapted: members are created, and therefore settle, in reverse of input order;
// deferred settlement is not expressible without host-async work in this corpus)
expect(
await value(`
const third = Promise.resolve("c")
const failing = (async () => { throw "b" })()
try { await failing } catch {}
const second = (async () => "b")()
const first = Promise.resolve("a")
return [
await Promise.all([first, second, third]),
await Promise.allSettled([first, failing, third]),
]
`),
).toEqual([
["a", "b", "c"],
[
{ status: "fulfilled", value: "a" },
{ status: "rejected", reason: "b" },
{ status: "fulfilled", value: "c" },
],
])
})
test("Promise.race ignores a rejected loser once the first contender wins", async () => {
// Source: test/built-ins/Promise/race/reject-ignored-immed.js
// (adapted: the losing rejection comes from an async function instead of a thenable;
// the exact-equality check also asserts the loser leaves no unhandled-rejection warning)
expect(
await execute(`
const loser = (async () => { throw "lost" })()
return await Promise.race([Promise.resolve("won"), loser])
`),
).toEqual({ ok: true, value: "won", toolCalls: [] })
})
test("Promise.race([]) returns a promise whose CodeMode failure is catchable", async () => {
// Sources:
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
// divergence rather than the spec never-settles behavior)
expect(
await value(`
const empty = Promise.race([])
try {
await empty
return "settled"
} catch (error) {
return [empty instanceof Promise, error instanceof Error]
}
`),
).toEqual([true, true])
})
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
// (adapted: no executor construction, and identity is observed with Array includes
// because promises are not comparable data values in CodeMode)
expect(
await value(`
const promise = Promise.resolve({ id: 1 })
return [
[promise].includes(Promise.resolve(promise)),
[promise].includes(Promise.resolve(Promise.resolve(promise))),
(await Promise.resolve(Promise.resolve(promise))).id,
]
`),
).toEqual([true, true, 1])
})
test("Promise.resolve of a rejected promise preserves identity and reason", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.3_T1.js
// (adapted: the source promise is already rejected instead of rejected later)
expect(
await value(`
const rejected = Promise.reject("oops")
const adopted = Promise.resolve(rejected)
const identity = [rejected].includes(adopted)
try {
await adopted
return "fulfilled"
} catch (reason) {
return [identity, reason]
}
`),
).toEqual([true, "oops"])
})
test("Promise.reject uses a promise reason without flattening it", async () => {
// Sources:
// test/built-ins/Promise/reject-via-fn-immed.js
// test/built-ins/Promise/reject-via-fn-deferred.js
// (adapted: the promise reason goes through Promise.reject instead of executor reject)
expect(
await value(`
const observe = async (reason) => {
try {
await Promise.reject(reason)
return "fulfilled"
} catch (caught) {
const identity = [reason].includes(caught)
try { return [identity, caught instanceof Promise, await caught] }
catch (inner) { return [identity, caught instanceof Promise, "rethrew " + inner] }
}
}
return [await observe(Promise.resolve(1)), await observe(Promise.reject("inner"))]
`),
).toEqual([
[true, true, 1],
[true, true, "rethrew inner"],
])
})
})
describe("Test262 async functions and await", () => {
test("declaration, expression, and arrow forms return promises", async () => {
// Sources:
// test/language/statements/async-function/declaration-returns-promise.js
// test/language/expressions/async-function/expression-returns-promise.js
// test/language/expressions/async-arrow-function/arrow-returns-promise.js
expect(
await value(`
async function declaration() { return 1 }
const expression = async function() { return 2 }
const arrow = async () => 3
const promises = [declaration(), expression(), arrow()]
return [promises.map((item) => item instanceof Promise), await Promise.all(promises)]
`),
).toEqual([[true, true, true], [1, 2, 3]])
})
test("async bodies adopt returns and reject throws before and after await", async () => {
// Sources:
// test/language/statements/async-function/evaluation-body.js
// test/language/statements/async-function/evaluation-body-that-returns.js
// test/language/statements/async-function/evaluation-body-that-returns-after-await.js
// test/language/statements/async-function/evaluation-body-that-throws.js
// test/language/statements/async-function/evaluation-body-that-throws-after-await.js
expect(
await value(`
const order = []
const plain = async () => { order.push("body"); return 42 }
const afterAwait = async () => { await Promise.resolve(); return 43 }
const throwsBefore = async () => { throw 1 }
const throwsAfter = async () => { await Promise.resolve(); throw 2 }
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
const first = plain()
return [
order,
await observe(first),
await observe(afterAwait()),
await observe(throwsBefore()),
await observe(throwsAfter()),
]
`),
).toEqual([
["body"],
["fulfilled", 42],
["fulfilled", 43],
["rejected", 1],
["rejected", 2],
])
})
test("default-parameter throws reject instead of escaping the call", async () => {
// Source: test/language/statements/async-function/evaluation-default-that-throws.js
expect(
await value(`
const fail = () => { throw new Error("default") }
const run = async (value = fail()) => value
let returned = false
try {
const promise = run()
returned = promise instanceof Promise
await promise
return [returned, "fulfilled"]
} catch (error) {
return [returned, error.message]
}
`),
).toEqual([true, "default"])
})
test("async try/finally completion records override earlier completion", async () => {
// Sources: the try-{return,throw,reject}-finally-{return,throw,reject}.js matrix under
// test/language/statements/async-function, test/language/expressions/async-function,
// and test/language/expressions/async-arrow-function.
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
const returnReturn = async () => { try { return "early" } finally { return await Promise.resolve("override") } }
const returnThrow = async () => { try { return "early" } finally { throw "override" } }
const returnReject = async () => { try { return "early" } finally { await Promise.reject("override") } }
const throwReturn = async () => { try { throw "early" } finally { return await Promise.resolve("override") } }
const throwThrow = async () => { try { throw "early" } finally { throw "override" } }
const throwReject = async () => { try { throw "early" } finally { await Promise.reject("override") } }
const rejectReturn = async () => { try { await Promise.reject("early") } finally { return await Promise.resolve("override") } }
const rejectThrow = async () => { try { await Promise.reject("early") } finally { throw "override" } }
const rejectReject = async () => { try { await Promise.reject("early") } finally { await Promise.reject("override") } }
return await Promise.all([
observe(returnReturn()), observe(returnThrow()), observe(returnReject()),
observe(throwReturn()), observe(throwThrow()), observe(throwReject()),
observe(rejectReturn()), observe(rejectThrow()), observe(rejectReject()),
])
`),
).toEqual([
["fulfilled", "override"],
["rejected", "override"],
["rejected", "override"],
["fulfilled", "override"],
["rejected", "override"],
["rejected", "override"],
["fulfilled", "override"],
["rejected", "override"],
["rejected", "override"],
])
})
test("await preserves an object whose then property is not callable", async () => {
// Source: test/language/expressions/await/await-awaits-thenable-not-callable.js
expect(
await value(`
const thenable = { then: 42 }
return (await thenable) === thenable
`),
).toBe(true)
})
test("await returns non-promise operands unchanged", async () => {
// Source: test/language/expressions/await/await-non-promise.js
// (adapted: only value pass-through is asserted here; the spec tick ordering around
// await of non-promises is covered by the failing interleaving test below)
expect(
await value(`
const object = { id: 1 }
const array = [1, 2]
return [
await 1,
await "text",
await true,
(await null) === null,
(await undefined) === undefined,
(await object) === object,
(await array) === array,
]
`),
).toEqual([1, "text", true, true, true, true, true])
})
})
describe("Test262 expected Promise conformance", () => {
for (const name of ["all", "allSettled", "race"] as const) {
test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js
// test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js
// test/built-ins/Promise/race/iter-arg-is-number-reject.js
expect(
await value(`
try {
const promise = Promise.${name}(42)
const returned = promise instanceof Promise
await promise
return [returned, "fulfilled"]
} catch (error) {
return [true, error.name]
}
`),
).toEqual([true, "TypeError"])
})
}
test.failing("Promise.all consumes sparse positions as undefined", async () => {
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
expect(
await value(`
const input = []
input[1] = 1
const result = await Promise.all(input)
return [result.length, result[0] === undefined, result[1]]
`),
).toEqual([2, true, 1])
})
test.failing("Promise.allSettled consumes sparse positions as undefined", async () => {
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
expect(
await value(`
const input = []
input[1] = 1
const result = await Promise.allSettled(input)
return [result.length, result[0].status, result[0].value === undefined, result[1]]
`),
).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }])
})
test.failing("Promise.race consumes a sparse first position as undefined", async () => {
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
expect(
await value(`
const input = []
input[1] = 1
return (await Promise.race(input)) === undefined
`),
).toBe(true)
})
test.failing("Promise.all settles after reactions attached to its inputs", async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js
expect(
await value(`
const sequence = [1]
const input = Promise.resolve(1)
const aggregate = Promise.all([input])
aggregate.then(() => sequence.push(4))
input.then(() => sequence.push(3)).then(() => sequence.push(5))
sequence.push(2)
await aggregate
await Promise.resolve()
return sequence
`),
).toEqual([1, 2, 3, 4, 5])
})
test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => {
// Sources:
// test/built-ins/Promise/allSettled/resolved-sequence.js
// test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js
// test/built-ins/Promise/allSettled/resolved-sequence-mixed.js
// test/built-ins/Promise/allSettled/resolved-sequence-with-rejections.js
expect(
await value(`
const sequence = [1]
const input = Promise.resolve(1)
const aggregate = Promise.allSettled([input])
aggregate.then(() => sequence.push(4))
input.then(() => sequence.push(3)).then(() => sequence.push(5))
sequence.push(2)
await aggregate
await Promise.resolve()
return sequence
`),
).toEqual([1, 2, 3, 4, 5])
})
test.failing("Promise.race settles in a reaction after its winning input", async () => {
// Sources:
// test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js
// test/built-ins/Promise/race/resolved-sequence-extra-ticks.js
expect(
await value(`
const sequence = [1]
const race = Promise.race([1])
race.then(() => sequence.push(4))
Promise.resolve().then(() => sequence.push(3)).then(() => sequence.push(5))
sequence.push(2)
await race
await Promise.resolve()
return sequence
`),
).toEqual([1, 2, 3, 4, 5])
})
test.failing("then reactions route and propagate fulfillment and rejection", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/prfm-fulfilled.js
// test/built-ins/Promise/prototype/then/prfm-rejected.js
// test/built-ins/Promise/prototype/then/rxn-handler-identity.js
// test/built-ins/Promise/prototype/then/rxn-handler-thrower.js
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-normal.js
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js
// test/built-ins/Promise/prototype/then/rxn-handler-rejected-return-normal.js
// test/built-ins/Promise/prototype/then/rxn-handler-rejected-return-abrupt.js
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return await Promise.all([
observe(Promise.resolve(1).then((value) => value + 1)),
observe(Promise.reject(2).then(undefined, (reason) => reason + 1)),
observe(Promise.resolve(3).then(undefined)),
observe(Promise.reject(4).then(undefined)),
observe(Promise.resolve(5).then(() => { throw 6 })),
observe(Promise.reject(7).then(undefined, () => { throw 8 })),
])
`),
).toEqual([
["fulfilled", 2],
["fulfilled", 3],
["fulfilled", 3],
["rejected", 4],
["rejected", 6],
["rejected", 8],
])
})
test.failing("then reactions preserve breadth-first queue order", async () => {
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js
expect(
await value(`
const sequence = [1]
const promise = Promise.resolve()
const first = promise.then(() => sequence.push(3)).then(() => sequence.push(5)).then(() => sequence.push(7))
const second = promise.then(() => sequence.push(4)).then(() => sequence.push(6)).then(() => sequence.push(8))
sequence.push(2)
await Promise.all([first, second])
return sequence
`),
).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js
// test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js
// test/built-ins/Promise/prototype/then/resolve-pending-fulfilled-self.js
// test/built-ins/Promise/prototype/then/resolve-pending-rejected-self.js
expect(
await value(`
const observe = async (promise) => {
try { await promise; return "fulfilled" } catch (reason) { return reason.name }
}
let fulfilled
let rejected
fulfilled = Promise.resolve().then(() => fulfilled)
rejected = Promise.reject().then(undefined, () => rejected)
return await Promise.all([observe(fulfilled), observe(rejected)])
`),
).toEqual(["TypeError", "TypeError"])
})
test.failing("catch delegates rejection handling and preserves fulfillment", async () => {
// Sources:
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T2.js
expect(
await value(`
return [
await Promise.resolve(1).catch(() => 2),
await Promise.reject(3).catch((reason) => reason + 1),
]
`),
).toEqual([1, 4])
})
test.failing("finally preserves or replaces the original settlement", async () => {
// Sources:
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
// test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js
// test/built-ins/Promise/prototype/finally/rejection-reason-override-with-throw.js
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return await Promise.all([
observe(Promise.resolve(1).finally(() => 2)),
observe(Promise.reject(3).finally(() => 4)),
observe(Promise.reject(5).finally(() => { throw 6 })),
])
`),
).toEqual([
["fulfilled", 1],
["rejected", 3],
["rejected", 6],
])
})
test.failing("await always resumes in a later reaction and interleaves async functions", async () => {
// Sources:
// test/language/expressions/await/async-await-interleaved.js
// test/language/expressions/await/await-non-promise.js
expect(
await value(`
const sequence = []
const first = async () => { sequence.push("first:1"); await 0; sequence.push("first:2") }
const second = async () => { sequence.push("second:1"); await 0; sequence.push("second:2") }
await Promise.all([first(), second()])
return sequence
`),
).toEqual(["first:1", "second:1", "first:2", "second:2"])
})
test.failing("an async function rejects when it resolves with its own promise", async () => {
// Adapted from the self-resolution requirement represented by:
// test/built-ins/Promise/resolve-self.js
// test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js
expect(
await value(`
let promise
const run = async () => {
await Promise.resolve()
return promise
}
promise = run()
try {
await promise
return "fulfilled"
} catch (error) {
return error.name
}
`),
).toBe("TypeError")
})
test.failing("Promise.resolve recursively assimilates callable thenables", async () => {
// Source: test/built-ins/Promise/resolve/resolve-thenable.js
expect(
await value(`
const value = { id: 1 }
const nested = { then: (resolve) => resolve(value) }
const thenable = { then: (resolve) => resolve(nested) }
return (await Promise.resolve(thenable)) === value
`),
).toBe(true)
})
test.failing("Promise combinators assimilate callable thenable inputs", async () => {
// Sources:
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/all/reject-ignored-immed.js
// test/built-ins/Promise/allSettled/reject-ignored-immed.js
// test/built-ins/Promise/race/resolve-thenable.js
expect(
await value(`
const fulfills = { then: (resolve) => resolve(1) }
const rejects = { then: (_, reject) => reject(2) }
const resolvesFirst = { then: (resolve, reject) => { resolve(3); reject(4) } }
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return [
await observe(Promise.all([fulfills, rejects])),
await Promise.allSettled([fulfills, resolvesFirst]),
await observe(Promise.race([rejects])),
]
`),
).toEqual([
["rejected", 2],
[
{ status: "fulfilled", value: 1 },
{ status: "fulfilled", value: 3 },
],
["rejected", 2],
])
})
test.failing("await assimilates callable thenables", async () => {
// Source: test/language/expressions/await/await-awaits-thenables.js
expect(
await value(`
const thenable = { then: (resolve) => resolve(42) }
return await thenable
`),
).toBe(42)
})
test.failing("await rejects when a callable thenable throws", async () => {
// Source: test/language/expressions/await/await-awaits-thenables-that-throw.js
expect(
await value(`
const error = { id: 1 }
const thenable = { then: () => { throw error } }
try {
await thenable
return false
} catch (caught) {
return caught === error
}
`),
).toBe(true)
})
})
+51 -453
View File
@@ -48,13 +48,6 @@ const failingTool = Tool.make({
run: () => Effect.fail(toolError("Lookup refused")),
})
const interruptedTool = Tool.make({
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
})
const completedTool = (trace: Trace) =>
Tool.make({
description: "Return the number of completed sleepy calls",
@@ -63,25 +56,6 @@ const completedTool = (trace: Trace) =>
run: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
const stubbornTool = (trace: Trace) =>
Tool.make({
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
run: ({ cleanupMs }) =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.andThen(
Effect.sleep(cleanupMs),
Effect.sync(() => {
trace.interrupted += 1
}),
),
),
),
})
const run = (
code: string,
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
@@ -89,15 +63,7 @@ const run = (
const trace = options.trace ?? makeTrace()
return Effect.runPromise(
CodeMode.execute({
tools: {
host: {
sleepy: sleepyTool(trace),
fail: failingTool,
interrupt: interruptedTool,
completed: completedTool(trace),
stubborn: stubbornTool(trace),
},
},
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } },
code,
...(options.limits ? { limits: options.limits } : {}),
}),
@@ -208,7 +174,8 @@ describe("first-class promise values", () => {
})
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
const result = await run(`
expect(
await value(`
const p = tools.host.fail({})
try {
await p
@@ -216,195 +183,57 @@ describe("first-class promise values", () => {
} catch (e) {
return e.message
}
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("Lookup refused")
expect(result.warnings).toBeUndefined()
`),
).toBe("Lookup refused")
})
test("a fire-and-forget call is interrupted when the program returns", async () => {
test("a fire-and-forget call completes before the execution ends", async () => {
const trace = makeTrace()
const result = await run(
const result = await value(
`
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
expect(result).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a never-awaited failing call preserves the result and reports the rejection", async () => {
const result = await run(`
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
const diagnostic = await error(`
tools.host.fail({})
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
])
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(diagnostic.message).toContain("Lookup refused")
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
})
test("a never-awaited failing async function is reported with a successful result", async () => {
const result = await run(`
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
const diagnostic = await error(`
const fail = async () => { throw new Error("boom") }
fail()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
expect(diagnostic.kind).toBe("ExecutionFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(diagnostic.message).toContain("boom")
})
test("output truncation bounds warning diagnostics with an in-band marker", async () => {
const result = await run(
`
for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
return "done"
`,
{ limits: { maxOutputBytes: 64 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.truncated).toBe(true)
expect(result.warnings).toStrictEqual([
{ kind: "Truncated", message: "100 additional warnings omitted by the output limit." },
])
})
test("a budget-consuming value does not starve warnings", async () => {
const result = await run(
`
Promise.reject(new Error("boom"))
return "x".repeat(500)
`,
{ limits: { maxOutputBytes: 128 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
test("an un-awaited async function's pending chain is interrupted at the return", async () => {
const trace = makeTrace()
const result = await run(
`
const run = async () => {
await tools.host.sleepy({ id: 1, ms: 60000 })
tools.host.fail({})
}
run()
return "done"
`,
{ trace },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
expect(trace.starts).toEqual([1])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("reports every unhandled rejection in promise creation order", async () => {
const result = await run(`
Promise.reject(new Error("first"))
tools.host.fail({})
Promise.reject(new Error("third"))
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
])
})
test("orders an async function rejection before promises created inside its body", async () => {
const result = await run(`
const outer = async () => {
Promise.reject(new Error("inner"))
throw new Error("outer")
test("drains promises started by an async function after an await", async () => {
const diagnostic = await error(`
const run = async () => {
await tools.host.sleepy({ id: 1 })
tools.host.fail({})
}
outer()
run()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
])
})
test("un-awaited interruptions settle without becoming rejections", async () => {
const result = await run(`
tools.host.interrupt({})
Promise.all([tools.host.interrupt({})])
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
})
test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
const trace = makeTrace()
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 1_000 })
throw new Error("boom")
`,
{ trace },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toBe("Uncaught: boom")
expect("warnings" in result).toBe(false)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("async-function promises remain owned by the execution after the function returns", async () => {
const trace = makeTrace()
expect(
await value(
`
const launch = async () => {
tools.host.sleepy({ id: 1, ms: 60000 })
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
return "returned"
}
return await launch()
`,
{ trace },
),
).toBe("returned")
// Both calls outlive launch() itself - they belong to the execution, not the function -
// and are interrupted only when the whole program returns.
expect(trace.starts).toEqual([1, 2])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(2)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Lookup refused")
})
})
@@ -422,22 +251,6 @@ describe("promises at data boundaries", () => {
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("invalid returned data cancels pending work", async () => {
const trace = makeTrace()
const result = await run(
`
const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
return { pending }
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("InvalidDataValue")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue")
@@ -457,59 +270,6 @@ describe("promises at data boundaries", () => {
})
describe("Promise.all over arbitrary arrays", () => {
test("combinators return promises that can be assigned and awaited later", async () => {
expect(
await value(`
const all = Promise.all([Promise.resolve(1)])
const settled = Promise.allSettled([Promise.reject("no")])
const race = Promise.race([Promise.resolve(2)])
const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
return [promises, await all, await settled, await race]
`),
).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
})
test("separately-created aggregate batches overlap before either is awaited", async () => {
const trace = makeTrace()
expect(
await value(
`
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
return [await first, await second]
`,
{ trace },
),
).toEqual([[1], [2]])
expect(trace.starts).toEqual([1, 2])
expect(trace.maxActive).toBeGreaterThan(1)
})
test("an aggregate created before a try block rejects at its later await", async () => {
expect(
await value(`
const aggregate = Promise.all([tools.host.fail({})])
try {
await aggregate
return "no"
} catch (error) {
return error.message
}
`),
).toBe("Lookup refused")
})
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
const result = await run(`
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
return [await aggregate, await aggregate]
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([[7], [7]])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("mixes promises and plain values, preserving order", async () => {
expect(
await value(`
@@ -580,18 +340,16 @@ describe("Promise.all over arbitrary arrays", () => {
})
test("rejects with the first failure, catchable in-program", async () => {
const result = await run(`
expect(
await value(`
try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
return "no"
} catch (e) {
return e.message
}
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("Lookup refused")
expect(result.warnings).toBeUndefined()
`),
).toBe("Lookup refused")
})
test("rejects before an earlier slow promise fulfills", async () => {
@@ -612,55 +370,10 @@ describe("Promise.all over arbitrary arrays", () => {
{ trace },
),
).toBe(0)
// The surviving member is observed (Promise.all handled it), so completion interrupts
// it instead of waiting for it.
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("fail-fast does not cancel a sibling the program still holds and awaits", async () => {
const trace = makeTrace()
expect(
await value(
`
const slow = tools.host.sleepy({ id: 1, ms: 40 })
try {
await Promise.all([slow, tools.host.fail({})])
return "no"
} catch {}
return await slow
`,
{ trace },
),
).toBe(1)
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a slower observed sibling is interrupted at completion after failing fast", async () => {
const trace = makeTrace()
expect(
await value(
`
const failLater = async () => {
await tools.host.sleepy({ id: 1, ms: 40 })
throw new Error("later")
}
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
try {
await aggregate
return "no"
} catch (error) {
return error.message
}
`,
{ trace },
),
).toBe("first")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a non-collection argument is a clear error", async () => {
const diagnostic = await error(`return await Promise.all(42)`)
expect(diagnostic.message).toContain("Promise.all expects an array")
@@ -700,64 +413,50 @@ describe("Promise.allSettled", () => {
return settled.filter((s) => s.status === "rejected").length
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe(2)
expect(result.warnings).toBeUndefined()
if (result.ok) expect(result.value).toBe(2)
})
})
describe("Promise.race", () => {
test("first settlement wins and a direct loser is interrupted at completion", async () => {
test("first settlement wins and losers are interrupted", async () => {
const trace = makeTrace()
const result = await value(
`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
return await Promise.race([fast, slow])
`,
{ trace },
)
expect(result).toBe(1)
// The loser is observed (the race handled it), so the execution does not wait for it.
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(1)
})
test("a direct loser remains awaitable after the race settles", async () => {
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow])
return { winner, loser: await slow }
try {
await slow
return "no"
} catch (e) {
return { winner, caught: e.message }
}
`),
).toEqual({ winner: 1, loser: 2 })
})
test("a nested aggregate loser and its members are interrupted at completion", async () => {
const trace = makeTrace()
expect(
await value(
`
const nested = Promise.all([
tools.host.sleepy({ id: 1, ms: 40 }),
tools.host.sleepy({ id: 2, ms: 40 }),
])
return await Promise.race(["immediate", nested])
`,
{ trace },
),
).toBe("immediate")
// The nested aggregate and its members are all observed, so nothing waits for them.
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(2)
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
})
test("a rejection can win the race", async () => {
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no"
} catch (e) {
return e.message
@@ -769,20 +468,11 @@ describe("Promise.race", () => {
test("a plain value wins over pending promises", async () => {
const trace = makeTrace()
expect(
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a rejected race loser is observed by the aggregate", async () => {
const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("winner")
expect(result.warnings).toBeUndefined()
})
test("an empty race is a clear error instead of hanging", async () => {
const diagnostic = await error(`return await Promise.race([])`)
expect(diagnostic.message).toContain("never settle")
@@ -794,9 +484,6 @@ describe("Promise.resolve / Promise.reject", () => {
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
true,
)
})
test("reject produces a promise whose await throws the reason", async () => {
@@ -811,34 +498,6 @@ describe("Promise.resolve / Promise.reject", () => {
`),
).toBe("nope")
})
test("a rejection observed after settlement is handled", async () => {
expect(
await value(`
const rejected = Promise.reject(new Error("handled"))
await tools.host.sleepy({ id: 1 })
try {
await rejected
return "no"
} catch (error) {
return error.message
}
`),
).toBe("handled")
})
test("an abandoned rejected promise is reported as unhandled", async () => {
const result = await run(`
Promise.reject(new Error("abandoned"))
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
])
})
})
describe("timeout interruption of forked calls", () => {
@@ -872,67 +531,6 @@ describe("timeout interruption of forked calls", () => {
expect(result.error.kind).toBe("TimeoutExceeded")
expect(trace.interrupted).toBe(2)
})
test("a non-settling race loser cannot hold the execution to the timeout", async () => {
const trace = makeTrace()
const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
trace,
limits: { timeoutMs: 100 },
})
// Completion interrupts the observed loser immediately; the race result survives.
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("winner")
expect(result.warnings).toBeUndefined()
expect(trace.starts).toEqual([1])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a timeout during completion cleanup keeps the computed value and warns", async () => {
const trace = makeTrace()
const result = await run(
`
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{
kind: "TimeoutExceeded",
message:
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
},
])
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(0)
})
test("a timeout during completion cleanup reports the timeout warning before settled rejections", async () => {
const result = await run(
`
tools.host.fail({})
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{
kind: "TimeoutExceeded",
message:
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
},
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
])
})
})
describe("unsupported promise surface", () => {
+77
View File
@@ -0,0 +1,77 @@
# Test262 Array Coverage
The Array tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 35
exposed instance methods and three static methods using actual arrays, accepted argument types, deterministic behavior,
and CodeMode's materialized collection conventions. Each executable case names its exact upstream source path.
`LICENSE.test262` contains the upstream BSD terms.
This is coverage of CodeMode's bounded Array surface, not a claim of ECMAScript or Test262 conformance. One upstream
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
were adapted.
## Inventory
The 38 relevant upstream API directories contain 2,837 files. The executable suite adapts assertions from 83 distinct
sources.
| API | Upstream files | Adapted sources |
| ------------------------------- | -------------: | --------------: |
| `Array.prototype.map` | 216 | 3 |
| `Array.prototype.filter` | 242 | 3 |
| `Array.prototype.find` | 23 | 4 |
| `Array.prototype.findIndex` | 23 | 3 |
| `Array.prototype.findLast` | 24 | 3 |
| `Array.prototype.findLastIndex` | 24 | 3 |
| `Array.prototype.some` | 219 | 2 |
| `Array.prototype.every` | 218 | 2 |
| `Array.prototype.includes` | 30 | 2 |
| `Array.prototype.join` | 23 | 2 |
| `Array.prototype.reduce` | 260 | 3 |
| `Array.prototype.reduceRight` | 260 | 3 |
| `Array.prototype.flatMap` | 24 | 2 |
| `Array.prototype.forEach` | 190 | 2 |
| `Array.prototype.sort` | 54 | 3 |
| `Array.prototype.toSorted` | 21 | 4 |
| `Array.prototype.slice` | 71 | 1 |
| `Array.prototype.concat` | 69 | 3 |
| `Array.prototype.indexOf` | 201 | 2 |
| `Array.prototype.lastIndexOf` | 198 | 2 |
| `Array.prototype.at` | 13 | 3 |
| `Array.prototype.flat` | 19 | 2 |
| `Array.prototype.reverse` | 18 | 1 |
| `Array.prototype.toReversed` | 17 | 2 |
| `Array.prototype.with` | 21 | 2 |
| `Array.prototype.push` | 24 | 1 |
| `Array.prototype.pop` | 23 | 1 |
| `Array.prototype.shift` | 20 | 1 |
| `Array.prototype.unshift` | 22 | 1 |
| `Array.prototype.splice` | 81 | 3 |
| `Array.prototype.fill` | 22 | 3 |
| `Array.prototype.copyWithin` | 39 | 2 |
| `Array.prototype.keys` | 12 | 1 |
| `Array.prototype.values` | 12 | 1 |
| `Array.prototype.entries` | 12 | 1 |
| `Array.from` | 47 | 3 |
| `Array.isArray` | 29 | 2 |
| `Array.of` | 16 | 1 |
## Exclusions
Assertions are not adapted when they test behavior outside CodeMode's documented Array surface:
- Function metadata, property descriptors, constructibility, prototype mutation, species constructors, or cross-realm
identity.
- Generic receivers, detached methods, `.call`, `.apply`, boxed values, custom coercion objects, Symbols, BigInts,
proxies, accessors, frozen arrays, typed arrays, or ArrayBuffers.
- `Array.from` mappers, custom iterables, constructor substitution, and iterator-closing behavior.
- Native iterator identity, `.next()`, completion records, or live iterator mutation. CodeMode deliberately materializes
`keys`, `values`, and `entries` as arrays.
- Sparse-array assertions that depend on literal elisions or inherited indexed properties. CodeMode's confined data
model does not preserve those prototype and hole semantics at every boundary.
- Argument coercions outside the accepted schema-like surface. Numeric positions must be numbers and `join` separators
must be strings.
- Exact native error brands where CodeMode exposes a safe runtime error instead.
- Async/effectful callbacks, circular-data rejection, sandbox-value identity, diagnostics, and host-boundary behavior.
Those remain covered by CodeMode-specific tests.
Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript Array semantics.
+69
View File
@@ -0,0 +1,69 @@
# Test262 String Coverage
The String tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 32
exposed instance methods and two static methods using primitive receivers, accepted argument types, and deterministic
behavior. Each executable case names its exact upstream source path. `LICENSE.test262` contains the upstream BSD terms.
This is coverage of CodeMode's bounded String surface, not a claim of ECMAScript or Test262 conformance. One upstream
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
were adapted.
## Inventory
The relevant upstream directories contain 1,048 files: 1,009 core built-in files, 29 Annex B files for exposed methods,
and 10 Intl `localeCompare` files. The executable suite adapts assertions from 298 distinct sources.
| API | Upstream files | Adapted sources |
| --- | ---: | ---: |
| `String.fromCharCode` | 17 | 6 |
| `String.fromCodePoint` | 11 | 4 |
| `String.prototype.at` | 11 | 5 |
| `String.prototype.charAt` | 30 | 9 |
| `String.prototype.charCodeAt` | 25 | 4 |
| `String.prototype.codePointAt` | 16 | 6 |
| `String.prototype.concat` | 22 | 1 |
| `String.prototype.endsWith` | 27 | 13 |
| `String.prototype.includes` | 27 | 12 |
| `String.prototype.indexOf` | 47 | 8 |
| `String.prototype.lastIndexOf` | 25 | 1 |
| `String.prototype.localeCompare` | 23 | 1 |
| `String.prototype.match` | 52 | 9 |
| `String.prototype.matchAll` | 26 | 1 |
| `String.prototype.normalize` | 14 | 3 |
| `String.prototype.padEnd` | 13 | 4 |
| `String.prototype.padStart` | 13 | 4 |
| `String.prototype.repeat` | 16 | 4 |
| `String.prototype.replace` | 56 | 16 |
| `String.prototype.replaceAll` | 46 | 12 |
| `String.prototype.search` | 44 | 10 |
| `String.prototype.slice` | 38 | 11 |
| `String.prototype.split` | 121 | 50 |
| `String.prototype.startsWith` | 21 | 7 |
| `String.prototype.substr` | 15 | 6 |
| `String.prototype.substring` | 46 | 12 |
| `String.prototype.toLowerCase` | 30 | 5 |
| `String.prototype.toString` | 7 | 1 |
| `String.prototype.toUpperCase` | 26 | 3 |
| `String.prototype.trim` | 129 | 66 |
| `String.prototype.trimEnd` | 23 | 2 |
| `String.prototype.trimLeft` | 4 | 0 |
| `String.prototype.trimRight` | 4 | 0 |
| `String.prototype.trimStart` | 23 | 2 |
## Exclusions
Assertions are not adapted when they test behavior outside CodeMode's documented String surface:
- Function metadata, property descriptors, constructibility, prototype mutation, or cross-realm identity.
- The `trimLeft`/`trimRight` Test262 files assert prototype function identity, which CodeMode does not expose. Their
supported call behavior remains covered by CodeMode-specific tests.
- Boxed strings, generic receivers, custom coercion objects, Symbols, BigInts, or argument types CodeMode rejects.
- Symbol-based RegExp dispatch, custom matchers, species constructors, or iterator protocol details. CodeMode materializes
`matchAll` results instead of exposing iterators.
- Locale selection and options. CodeMode deliberately uses the host default locale and ignores those arguments.
- Test262 harness behavior or setup syntax unavailable in the confined interpreter.
- Function-replacer behavior that is covered by CodeMode-specific tests for sequential callbacks, async tool calls,
result coercion, diagnostics, and sandbox boundaries.
- Assertions requiring an exact native error type when CodeMode deliberately exposes only its safe runtime error.
Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript String semantics.
+16 -88
View File
@@ -15,43 +15,20 @@ import type {
SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
APIError,
Authentication,
BadRequest,
ConnectionError,
FinishReason,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
InvalidProviderOutputReason,
LLMEvent,
MalformedResponse,
LLMError,
Model,
NotFound,
ProviderID,
ProviderMetadata,
ToolResultValue,
classifyApiFailure,
isLLMError,
type LLMError,
UnknownProviderReason,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/llm"
import {
APICallError,
EmptyResponseBodyError,
InvalidArgumentError,
InvalidPromptError,
InvalidResponseDataError,
JSONParseError,
LoadAPIKeyError,
LoadSettingError,
NoContentGeneratedError,
NoSuchModelError,
TypeValidationError,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
@@ -513,12 +490,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: (error) => llmError(error),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: (error) => llmError(error),
onError: (error) => llmError("readStream", error),
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@@ -631,7 +608,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError(event.error))
return Effect.fail(llmError("stream", event.error))
}
}
@@ -689,65 +666,16 @@ function messageValue(input: unknown) {
}
}
const BODY_LIMIT = 16_384
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
if (!headers) return undefined
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = headers["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return undefined
}
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
// AI SDK route reports failures identically to native protocol routes. An
// `APICallError` without a status code is the AI SDK's representation of a
// network-level failure (connect refused, reset, DNS), not an API rejection.
function llmError(error: unknown): LLMError {
if (isLLMError(error)) return error
if (APICallError.isInstance(error)) {
if (error.statusCode === undefined) {
return new ConnectionError({ message: error.message, url: error.url, cause: error })
}
return classifyApiFailure({
message: error.message,
status: error.statusCode,
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
}),
})
}
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
return new Authentication({ message: error.message })
}
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
if (
InvalidPromptError.isInstance(error) ||
InvalidArgumentError.isInstance(error) ||
UnsupportedFunctionalityError.isInstance(error)
) {
return new BadRequest({ message: error.message })
}
if (
InvalidResponseDataError.isInstance(error) ||
JSONParseError.isInstance(error) ||
TypeValidationError.isInstance(error) ||
EmptyResponseBodyError.isInstance(error) ||
NoContentGeneratedError.isInstance(error)
) {
return new MalformedResponse({ message: error.message })
}
return new APIError({ message: error instanceof Error ? error.message : String(error) })
function llmError(method: string, error: unknown) {
const reason =
error instanceof LLMError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
return new LLMError({
module: "AISDK",
method,
reason,
})
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
+19 -29
View File
@@ -119,11 +119,6 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
path: AbsolutePath,
}) {}
export class File extends Schema.Class<File>("Config.File")({
type: Schema.Literal("file"),
path: AbsolutePath,
}) {}
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
type: Schema.Literal("agents"),
path: AbsolutePath,
@@ -134,7 +129,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
path: AbsolutePath,
}) {}
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
@@ -143,7 +138,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
}
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
/** Returns location config documents and supplemental directories from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
}
@@ -232,36 +227,31 @@ const layer = Layer.effect(
const directPaths = discovered
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
const direct = yield* Effect.forEach(directPaths, (filepath) =>
loadFile(filepath).pipe(
Effect.map((config) => [
...(config ? [config] : []),
new File({ type: "file", path: AbsolutePath.make(filepath) }),
]),
),
).pipe(
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
return {
entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
const targets = [
...directories.map((path) => ({ path, type: "directory" as const })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
const next = new Map(targets.map((target) => [JSON.stringify(target), target]))
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
for (const [key, stop] of subscriptions) {
if (next.has(key)) continue
yield* stop
@@ -282,7 +272,7 @@ const layer = Layer.effect(
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
+26 -1
View File
@@ -15,6 +15,7 @@ import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
import { AgentPlugin } from "../../plugin/agent"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
@@ -76,7 +77,31 @@ export const Plugin = define({
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
draft.update(current.id, (agent) => {
const defaults = AgentV2.Info.empty(AgentV2.ID.make(current.id)).permissions
const hasDefaults = defaults.every((rule, index) => {
const existing = agent.permissions[index]
return (
existing?.action === rule.action &&
existing.resource === rule.resource &&
existing.effect === rule.effect
)
})
const initial = hasDefaults ? defaults.length : 0
const hasBuiltInDefaults = hasDefaults && AgentPlugin.defaultPermissions.every((rule, index) => {
const existing = agent.permissions[initial + index]
return (
existing?.action === rule.action &&
existing.resource === rule.resource &&
existing.effect === rule.effect
)
})
agent.permissions.splice(
hasBuiltInDefaults ? initial + AgentPlugin.defaultPermissions.length : initial,
0,
...permissions,
)
})
}
for (const document of loaded.documents) {
+23 -25
View File
@@ -16,9 +16,6 @@ export type Info = typeof Info.Type
export const Field = Form.Field
export type Field = Form.Field
export const Fields = Form.Fields
export type Fields = Form.Fields
export const When = Form.When
export type When = Form.When
@@ -67,7 +64,9 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
message: Schema.String,
}) {}
export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
export interface ReplyInput {
readonly id: ID
@@ -75,7 +74,7 @@ export interface ReplyInput {
}
export interface ListInput {
readonly sessionID?: Form.Info["sessionID"]
readonly sessionID?: Form.FormInfo["sessionID"]
}
export interface Interface {
@@ -126,15 +125,20 @@ export const layer = Layer.effect(
const id = input.id ?? ID.create()
const existing = yield* Cache.getSuccess(forms, id)
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
const invalid = validateFields(input.fields)
if (invalid) return yield* new InvalidFormError({ message: invalid })
const form: Info = {
if (input.mode === "form") {
const invalid = validateFields(input.fields)
if (invalid) return yield* new InvalidFormError({ message: invalid })
}
const base = {
id,
sessionID: input.sessionID,
title: input.title,
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
@@ -224,16 +228,16 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
if (form.mode === "url") {
if (Object.keys(answer).length === 0) return
return "URL forms must be answered with an empty answer"
}
const fields = new Map(form.fields.map((field) => [field.key, field]))
for (const key of Object.keys(answer)) {
if (!fields.has(key)) return `Unknown form field: ${key}`
}
for (const field of form.fields) {
const value = answer[field.key]
if (field.type === "external") {
if (value !== true) return `External form field must be acknowledged: ${field.key}`
continue
}
const active = isActive(field, answer)
if (value === undefined) {
if (field.required && active) return `Missing required form field: ${field.key}`
@@ -245,9 +249,7 @@ function validateAnswer(form: Info, answer: Answer) {
}
}
type InputField = Exclude<Form.Field, Form.ExternalField>
function isActive(field: InputField, answer: Answer) {
function isActive(field: Form.Field, answer: Answer) {
if (!field.when) return true
return field.when.every((when) => matches(when, answer[when.key]))
}
@@ -265,13 +267,9 @@ function matches(when: Form.When, value: Form.Value | undefined) {
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
// silently never matching.
function validateFields(fields: ReadonlyArray<Form.Field>) {
if (fields.length === 0) return "Form must have at least one field"
const earlier = new Map<string, InputField>()
const keys = new Set<string>()
const earlier = new Map<string, Form.Field>()
for (const field of fields) {
if (keys.has(field.key)) return `Duplicate form field key: ${field.key}`
keys.add(field.key)
if (field.type === "external") continue
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
for (const when of field.when ?? []) {
const target = earlier.get(when.key)
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
@@ -282,7 +280,7 @@ function validateFields(fields: ReadonlyArray<Form.Field>) {
}
}
function validateWhen(when: Form.When, target: InputField) {
function validateWhen(when: Form.When, target: Form.Field) {
if (target.type === "boolean") {
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
return
@@ -299,7 +297,7 @@ function validateWhen(when: Form.When, target: InputField) {
}
}
function validateField(field: InputField, value: Form.Value): string | undefined {
function validateField(field: Form.Field, value: Form.Value): string | undefined {
if (field.type === "string") {
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Generate from "./generate"
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "./effect/app-node"
+7 -8
View File
@@ -123,7 +123,6 @@ type ServerEntry = {
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
const GLOBAL_ELICITATION_SESSION_ID = "global"
const URL_ELICITATION_FIELD_KEY = "elicitation"
export interface Interface {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -312,7 +311,8 @@ export const layer = Layer.effect(
elicitationID: input.params.elicitationId,
message: input.params.message,
},
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
mode: "url",
url: input.params.url,
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
@@ -325,16 +325,15 @@ export const layer = Layer.effect(
)
}
const params = input.params
const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
)
if (!field) return { action: "accept", content: {} }
return yield* forms
.ask({
sessionID: GLOBAL_ELICITATION_SESSION_ID,
title: `${input.server} is requesting input`,
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
fields: [field, ...fields],
mode: "form",
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
),
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
@@ -356,7 +355,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
+30 -25
View File
@@ -13,6 +13,21 @@ import { PermissionV2 } from "../permission"
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.tmp, "*"), effect: "allow" },
]
export const defaultPermissions: PermissionV2.Ruleset = [
...readonlyExternalDirectory.slice(1),
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
@@ -104,24 +119,6 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: PermissionV2.Ruleset = [
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => {
@@ -129,7 +126,7 @@ export const Plugin = define({
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
...PermissionV2.merge(defaultPermissions, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
@@ -141,7 +138,7 @@ export const Plugin = define({
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
...PermissionV2.merge(defaultPermissions, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
@@ -161,7 +158,9 @@ export const Plugin = define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
item.permissions.push(
...PermissionV2.merge(defaultPermissions, [{ action: "subagent", resource: "*", effect: "deny" }]),
)
})
draft.update(AgentV2.ID.make("explore"), (item) => {
@@ -172,7 +171,7 @@ export const Plugin = define({
item.mode = "subagent"
item.permissions.push(
...PermissionV2.merge(
defaults,
defaultPermissions,
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
@@ -192,7 +191,9 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push(
...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]),
)
})
draft.update(AgentV2.ID.make("title"), (item) => {
@@ -200,7 +201,9 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push(
...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]),
)
})
draft.update(AgentV2.ID.make("summary"), (item) => {
@@ -208,7 +211,9 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push(
...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]),
)
})
})
}),
+2
View File
@@ -1,6 +1,7 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node"
@@ -8,6 +9,7 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks
}
+1
View File
@@ -367,6 +367,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
runtime.session.create({
id: input?.id,
+4 -25
View File
@@ -1,9 +1,7 @@
export * as PluginPromise from "./promise"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
import { Effect, Scope, Stream } from "effect"
import { Tool } from "../tool/tool"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
@@ -110,14 +108,7 @@ export function fromPromise(plugin: PromisePlugin) {
reload: () => run(host.skill.reload()),
},
tool: {
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
}),
),
),
transform: transform(host.tool),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
@@ -127,24 +118,12 @@ export function fromPromise(plugin: PromisePlugin) {
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
}),
})
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
})
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
})
}
+28 -78
View File
@@ -4,39 +4,17 @@ Use this guide as the starting point for work involving OpenCode itself. It
covers the core concepts needed to configure and customize OpenCode, extend it
with plugins, and build integrations with the OpenCode SDK, clients, and API.
Full documentation is available at <https://v2.opencode.ai/>. This overview is
only an index of core concepts. Before answering a question about a topic below,
fetch the URL named in that section and use the full page as the source of
truth. Follow links from that page when the question needs more detail. Fetch
<https://v2.opencode.ai/llms.txt> first when you need to discover the relevant
documentation page.
Full documentation is available at <https://opencode.mintlify.site/>. Consult
it when this overview does not contain enough detail for the task.
## Version policy
Always answer for OpenCode V2 unless the user explicitly asks about V1,
legacy OpenCode, or migrating from V1.
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
general web search to resolve a V2 documentation question when the V2 docs or
their `llms.txt` index cover it. The schema served from
<https://opencode.ai/config.json> may describe V1 even though V2 configuration
files include that URL for editor integration. Never use it to infer V2 field
names or shapes. If V2 documentation is missing or contradictory, state the
uncertainty or ask for clarification instead of falling back to V1.
V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
must still use V2 unless the user specifically requests a V1 result.
## [Configuration](https://v2.opencode.ai/config)
## Configuration
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/config.json"
}
```
@@ -45,56 +23,45 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
When OpenCode starts, it searches upward from the current directory for project
configuration and merges the files it finds with the global configuration.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
`references`, `formatter`, and `lsp`.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
linked topic guide as the source of truth, and preserve unrelated settings when
editing an existing file. Keep the published `$schema` URL in configuration
examples, but do not fetch it to determine the V2 configuration shape.
Do not guess field names or shapes. Use
<https://opencode.ai/config.json> as the source of truth and preserve unrelated
settings when editing an existing file.
See the [full configuration guide](https://v2.opencode.ai/config) for
See the [full configuration guide](https://opencode.mintlify.site/config) for
every field, examples, config locations, and links to dedicated feature guides.
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
## V1 to V2 migration
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
[migration guide](https://opencode.mintlify.site/migrate-v1) before acting. In
the repository, its source is `packages/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
V2 config uses more ergonomic shapes, but conversion is optional. When the user
requests conversion, inspect the complete configuration, preserve behavior and
unrelated settings, and apply only the relevant migrations from the guide. For
plugin migrations, fetch and follow both the migration guide and the full
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
functionality fails in V2, use the `report` skill to file it as a compatibility
bug.
unrelated settings, and apply only the relevant migrations from the guide. If
the request includes a V1 plugin, explain that its API is not finalized and do
not attempt migration yet. Once the V2 plugin API is finalized, OpenCode should
be able to migrate most plugins. If non-API V1 functionality fails in V2, use
the `report` skill to file it as a compatibility bug.
## [Plugins](https://v2.opencode.ai/build/plugins)
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
## Service
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
to a background OpenCode service, which owns sessions, configuration, plugins,
permissions, and tool execution.
OpenCode normally discovers or starts the shared background service
automatically. If the service is stuck or unhealthy, restart it:
Configuration and related files are typically watched and reloaded while the
service is running. If a change does not appear, restart the service:
```sh
opencode2 service restart
@@ -106,15 +73,14 @@ Check its status after restarting:
opencode2 service status
```
## [API](https://v2.opencode.ai/api)
## API
OpenCode exposes an HTTP API from its server. The API is described by an
OpenAPI document available from the running server at `/openapi.json`.
Use OpenCode's built-in `api` command for local requests. It uses the same
discovery and authentication flow as the TUI and may start the background
service when no compatible healthy service is available. It accepts either an
HTTP method and path or an OpenAPI operation ID.
Use OpenCode's built-in `api` command for local requests. It discovers the same
background server used by the TUI, starts it when necessary, and applies the
server's authentication headers automatically.
Call an endpoint with an HTTP method and path:
@@ -135,34 +101,18 @@ connected to an explicit server instead of its managed background service, use
the same configured server and authentication context rather than constructing
an unauthenticated request separately.
See the [full API reference](https://v2.opencode.ai/api) for available
See the [full API reference](https://opencode.mintlify.site/api) for available
endpoints, parameters, request bodies, and response schemas. The
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
raw [OpenAPI specification](https://opencode.mintlify.site/openapi.json) is also
available for code generation and other tooling.
## [Client](https://v2.opencode.ai/build/client)
For questions about connecting an application to OpenCode over the network,
fetch the full [client guide](https://v2.opencode.ai/build/client) before
answering.
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
API. Its methods and types come from the same contract as the API reference.
The default entrypoint exposes Promise-based resource clients and async
iterables for streaming endpoints. The `@opencode-ai/client/effect` entrypoint
exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
## Troubleshooting
OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode2 service status` and verify the API with
`opencode2 api get /api/health`.
- Compare with `opencode2 --standalone`, which runs the TUI with a private
server, to isolate shared-service issues.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
client startup and `role=server` for sessions, providers, plugins,
permissions, and tools.
@@ -174,6 +124,6 @@ problem belongs to the client, the shared server, or one project.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
See the [full troubleshooting guide](https://opencode.mintlify.site/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.
+81 -43
View File
@@ -72,6 +72,23 @@ type Operation =
readonly target: string
}
type Candidate =
| {
readonly type: "definition"
readonly definition: Plugin
}
| {
readonly type: "package"
readonly specifier: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
type ConfiguredPackage = {
readonly operation: Extract<Operation, { type: "add" }>
enabled: boolean
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
@@ -92,14 +109,13 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
@@ -120,66 +136,88 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
post: readonly Plugin[],
operations: readonly Operation[],
) {
const plan = apply(pre, post, operations)
return yield* load(plan)
})
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin>()
const plugins = () => [...definitions, ...packages.values()]
const plugins = [...pre, ...post]
const enabled = new Set(plugins.map((plugin) => plugin.id))
const packages = new Map<string, ConfiguredPackage>()
for (const operation of operations) {
if (operation.type === "remove") {
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = false
})
continue
}
const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
const selectsPlugins =
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
const selectsDefinitions =
matched.length > 0 ||
operation.target === "*" ||
operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.")
if (selectsPlugins) {
if (selectsDefinitions) {
matched.forEach((plugin) => enabled.add(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = true
})
continue
}
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
if (!plugin) continue
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
packages.set(operation.target, { operation, enabled: true })
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
})
const definitions: Candidate[] = pre.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
item.enabled
? [
{
type: "package",
specifier: item.operation.target,
options: item.operation.options,
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
},
]
: [],
)
const posts: Candidate[] = post.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
return [...definitions, ...configured, ...posts]
}
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target)).entrypoint
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
? entrypoint
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
return yield* Effect.forEach(plan, (candidate) => {
if (candidate.type === "definition") return Effect.succeed(candidate.definition)
return Effect.gen(function* () {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(candidate.specifier)
? pathToFileURL(candidate.specifier).href
: (yield* npm.add(candidate.specifier)).entrypoint
if (!entrypoint) return undefined
// Bun currently ignores query parameters when caching file:// imports.
const source =
candidate.mtime === undefined
? entrypoint
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
} satisfies Plugin
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
+1 -1
View File
@@ -581,7 +581,7 @@ const layer = Layer.effect(
yield* shellLocks.withLock(input.sessionID)(
Effect.gen(function* () {
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
+152 -161
View File
@@ -1,8 +1,7 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node"
@@ -11,12 +10,12 @@ import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -60,14 +59,20 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly config: readonly Config.Entry[]
}
export type AutoInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Info[]
readonly request: LLMRequest
}
type CompactInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Info[]
readonly model: Model
readonly inputID?: SessionMessage.ID
}
export type ManualInput = {
@@ -76,27 +81,16 @@ export type ManualInput = {
readonly inputID: SessionMessage.ID
}
type Plan = {
readonly sessionID: SessionSchema.ID
readonly model: Model
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
}
export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
readonly required: (input: AutoInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
@@ -160,33 +154,30 @@ const select = (
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction")
.flatMap((message) => {
const text = serialize(message)
return text ? [{ message, text }] : []
})
.map(serialize)
.filter(Boolean)
if (conversation.length === 0) return undefined
let total = 0
let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break
const next = total + Token.estimate(conversation[index])
if (next > tokens) {
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
total = next
split = index
}
while (split > 0 && conversation[split].message.type !== "user") split--
if (split === 0) {
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser
}
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: conversation
.slice(split)
.map((item) => item.text)
.join("\n\n"),
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
}
}
@@ -199,161 +190,146 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
...input.context,
].join("\n\n")
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
const selected = select(messages, tokens)
if (!selected) return
const previousSummary = messages.findLast(
(message) => message.type === "compaction" && message.status === "completed",
)
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
const summarizeRecent = !previousRecent && !selected.head
return {
prompt: buildPrompt({
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
recent: summarizeRecent ? "" : selected.recent,
}
}
const make = (dependencies: Dependencies) => {
const config = dependencies.config
const failed = Effect.fnUntraced(function* (input: {
const config = settings(dependencies.config)
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly model: Model
readonly reason: SessionMessage.Compaction["reason"]
readonly error: SessionError.Error
readonly previousSummary?: string
readonly context: readonly string[]
readonly recent: string
readonly output?: number
readonly inputID?: SessionMessage.ID
}) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: plan.sessionID,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
sessionID: input.sessionID,
reason: input.reason,
recent: input.recent,
inputID: input.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
yield* dependencies.llm
let failed = false
const summarized = yield* dependencies.llm
.stream(
LLM.request({
model: plan.model,
messages: [Message.user(plan.prompt)],
model: input.model,
messages: [Message.user(summaryPrompt)],
tools: [],
generation: { maxTokens: summaryOutput },
}),
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.sessionID,
sessionID: input.sessionID,
text: event.text,
})
}
return Effect.void
}),
Effect.catchIf(isLLMError, (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() =>
plan.reason === "auto"
? failed({
sessionID: plan.sessionID,
reason: plan.reason,
input.reason === "auto"
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
inputID: input.inputID,
})
: Effect.void,
),
)
const summary = chunks.join("")
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: plan.sessionID,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.sessionID,
reason: plan.reason,
text: summary,
recent: plan.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, config.tokens)
if (content)
return yield* execute({
if (!summarized || failed || !summary.trim()) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
model: input.model,
reason: "auto",
...content,
})
const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" }
return yield* failed({
sessionID: input.sessionID,
reason: "auto",
error,
})
})
const required = (input: AutoInput) => {
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = input.model.route.defaults.limits?.output ?? 0
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= context - (output || config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
reason: input.reason,
error: { type: "compaction.failed", message: "Compaction produced no summary" },
inputID: input.inputID,
})
const resolved = yield* dependencies.models.resolve(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
sessionID: input.session.id,
model: resolved.model,
reason: "manual",
inputID: input.inputID,
...content,
return false
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
reason: input.reason,
text: summary,
recent: input.recent,
})
return true
})
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
return yield* compactSelected({
sessionID: input.sessionID,
messages: input.messages,
model: input.request.model,
reason: "auto",
force: false,
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
})
})
return Service.of({
required,
compact,
compactManual,
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
input: CompactInput & {
readonly reason: SessionMessage.Compaction["reason"]
readonly force: boolean
readonly output?: number
},
) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
return yield* compact({
sessionID: input.sessionID,
model: input.model,
reason: input.reason,
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
Boolean,
),
recent: forcedShortContext ? "" : selected.recent,
output: input.output,
inputID: input.inputID,
})
})
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
return yield* compactSelected({ ...input, reason: "manual", force: true })
})
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
if (!config.auto) return false
const context = input.request.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
if (
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
context - Math.max(output, config.buffer)
)
return false
return yield* compactAfterOverflow(input)
})
return {
compactIfNeeded,
compactAfterOverflow,
compactManual,
}
}
export const layer = Layer.effect(
@@ -363,7 +339,22 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
return make({ events, llm, models, config: settings(yield* config.entries()) })
const compaction = make({ events, llm, config: yield* config.entries() })
return Service.of({
compactIfNeeded: compaction.compactIfNeeded,
compactAfterOverflow: compaction.compactAfterOverflow,
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return false
return yield* compaction.compactManual({
sessionID: input.session.id,
messages: input.messages,
model: resolved.model,
inputID: input.inputID,
})
}),
})
}),
)
+8 -6
View File
@@ -28,6 +28,7 @@ type Execution<E, Reason> = {
owner?: Fiber.Fiber<void>
pendingWake: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
}
@@ -73,6 +74,7 @@ export const make = <Key, E, Reason = never>(options: {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
settling: false,
}
executions.set(key, execution)
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
@@ -84,7 +86,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.andThen(loop(key, execution, force)),
Effect.onExit((exit) =>
Effect.sync(() => {
execution.owner = undefined
execution.settling = true
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
@@ -104,14 +106,14 @@ export const make = <Key, E, Reason = never>(options: {
}
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
Effect.uninterruptibleMask((restore) => {
const execution = executions.get(key)
if (execution !== undefined) {
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
if (execution.stopping) return restore(Deferred.await(execution.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(execution.done))
}
return Deferred.await(start(key, true).done)
return restore(Deferred.await(start(key, true).done))
})
const wake = (key: Key) =>
@@ -127,7 +129,7 @@ export const make = <Key, E, Reason = never>(options: {
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
execution.stopping = true
execution.pendingWake = false
execution.interruptionReason = reason
+150 -30
View File
@@ -1,10 +1,20 @@
export * as SessionRunnerLLM from "./llm"
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
@@ -41,6 +51,8 @@ import { llmClient } from "../../effect/app-node-platform"
import { AgentNotFoundError, StepFailedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { PluginHooks } from "../../plugin/hooks"
import { PluginSupervisor } from "../../plugin/supervisor"
type StepTokens = {
@@ -68,8 +80,53 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
}
/**
* Runs one durable coding-agent Session until it settles. Each step reloads projected history,
* materializes tools, makes one model request, and settles local calls before continuation.
* Runs one durable coding-agent Session until it settles.
*
* Keep this as orchestration over smaller collaborators rather than rebuilding the legacy
* `SessionPrompt` monolith. Implement the unchecked items in small reviewed slices:
*
* - Session ownership and controls
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Honor optional agent step limits.
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
*
* - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
*
* - One step
* - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
* - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
*
* - Tool settlement and continuation
* - [x] Durably record each tool call before side effects begin.
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
* plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit step after local tool results.
* - [x] Continue for durable user steering accepted during an active step.
* - [ ] Continue for compaction or another continuation condition when required.
*
* - Post-run maintenance
* - [ ] Settle final status and expose durable output events to replayable consumers.
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
*
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
* step. Registry definitions are advertised, local tool calls are settled durably, and an
* explicit loop starts the next step after local settlement. Configured agent step limits bound the loop.
*/
const layer = Layer.effect(
@@ -79,6 +136,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
@@ -115,7 +173,7 @@ const layer = Layer.effect(
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
@@ -147,7 +205,7 @@ const layer = Layer.effect(
sessionID: SessionSchema.ID,
promotion: SessionPending.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compact,
recoverOverflow?: typeof compaction.compactAfterOverflow,
assistantMessageID?: SessionMessage.ID,
) {
const session = yield* getSession(sessionID)
@@ -180,14 +238,10 @@ const layer = Layer.effect(
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const compactionInput = { sessionID: session.id, messages: context, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error })
}
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agentInfo.permissions, model })
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -205,6 +259,36 @@ const layer = Layer.effect(
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const requestEvent: SessionHooks["request"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft, but cannot advertise tools excluded earlier
// by permissions or registration state.
yield* hooks.trigger("session", "request", requestEvent)
const hookedRequest = LLM.updateRequest(request, {
system: requestEvent.system,
messages: requestEvent.messages,
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
// Automatic compaction completed; rebuild the request from compacted history.
if (
!(yield* SessionPending.compaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
@@ -221,10 +305,17 @@ const layer = Layer.effect(
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
const providerStream = llm.stream(request).pipe(
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (publisher.hasProviderError()) return
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
@@ -236,6 +327,21 @@ const layer = Layer.effect(
)
return
}
// A request hook hid this registered tool from the current request. Fail only
// this call durably and continue so the model can react, instead of executing
// a tool that was not advertised. Unregistered tools flow through settle, which
// durably fails them as unknown.
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
needsContinuation = true
yield* publish(
LLMEvent.toolError({
id: event.id,
name: event.name,
message: `Tool is not available for this request: ${event.name}`,
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
@@ -304,21 +410,21 @@ const layer = Layer.effect(
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasRetryEvidence() &&
llmFailure?._tag === "LLM.ContextOverflow" &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
"completed"
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// A thrown LLM failure records the assistant failure unless a provider failure
// was already recorded from the stream. Terminal publication waits for owned tools.
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (
@@ -335,8 +441,7 @@ const layer = Layer.effect(
}
yield* serialized(publisher.failAssistant(error))
}
// The provider-failed flag is only set while consuming the stream (content-filter
// step finish), so it is final here.
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
@@ -352,8 +457,8 @@ const layer = Layer.effect(
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
const userDeclined = settledCauses.some(isUserDeclined)
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
if (userDeclined || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
}
@@ -361,7 +466,9 @@ const layer = Layer.effect(
// implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
const settledFailure = settledCauses.find(
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause),
)
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
@@ -404,7 +511,9 @@ const layer = Layer.effect(
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
if (stepFailure)
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
@@ -416,7 +525,7 @@ const layer = Layer.effect(
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return {
_tag: "Completed",
needsContinuation,
needsContinuation: !providerFailed && needsContinuation,
step: currentStep,
} as const
}),
@@ -431,7 +540,7 @@ const layer = Layer.effect(
// Compaction restarts rebuild the request from compacted history without re-promoting.
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
// overflow, so the recovery hook is dropped after it fires.
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion
let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
@@ -471,7 +580,7 @@ const layer = Layer.effect(
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionPending.compaction(db, sessionID)
if (!pending) return
if (!pending) return false
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -484,7 +593,7 @@ const layer = Layer.effect(
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return
if (Exit.isSuccess(compacted) && compacted.value) return true
if (Exit.isFailure(compacted)) {
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
@@ -496,6 +605,15 @@ const layer = Layer.effect(
})
return yield* Effect.failCause(compacted.cause)
}
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: "Compaction could not start" },
inputID: unsettled.id,
})
return true
}),
)
})
@@ -557,6 +675,7 @@ export const node = makeLocationNode({
llmClient,
AgentV2.node,
ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node,
SessionStore.node,
Location.node,
@@ -568,6 +687,7 @@ export const node = makeLocationNode({
InstructionEntry.node,
SessionCompaction.node,
SessionTitle.node,
Config.node,
Snapshot.node,
Database.node,
PluginSupervisor.node,
@@ -57,12 +57,13 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
}
/** Persist one step without executing tools or starting a continuation step. */
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
string,
{
readonly assistantMessageID: SessionMessage.ID
readonly name: string
inputEnded: boolean
called: boolean
settled: boolean
providerExecuted: boolean
@@ -139,7 +140,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const flush = Effect.fnUntraced(function* () {
for (const id of chunks.keys()) yield* end(id)
})
return { start, append, end, flush, has: (id: string) => chunks.has(id) }
return { start, append, end, flush }
}
const text = fragments(
@@ -179,6 +180,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
callID,
text: value,
})
tool.inputEnded = true
}),
)
@@ -194,6 +196,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
tools.set(event.id, {
assistantMessageID,
name: event.name,
inputEnded: false,
called: false,
settled: false,
providerExecuted: false,
@@ -212,7 +215,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
if (tool.inputEnded) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
yield* toolInput.end(event.id)
})
@@ -327,7 +330,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
if (!tool) return yield* Effect.die(new Error(`Tool input delta before start: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
if (tool.inputEnded) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
@@ -344,7 +347,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
retryEvidence = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (toolInput.has(event.id)) yield* endToolInput(event)
if (!tool.inputEnded) yield* endToolInput(event)
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
@@ -361,6 +364,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
}
case "tool-result": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
@@ -397,6 +401,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
}
case "tool-error": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
if (tool.name !== event.name)
@@ -428,6 +433,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
case "finish":
return
case "provider-error":
providerFailed = true
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
return
}
})
+15 -19
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry"
import type { LLMError } from "@opencode-ai/llm"
import { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
@@ -17,33 +17,29 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
}> {}
export function isRetryable(error: LLMError) {
switch (error._tag) {
case "LLM.RateLimit":
case "LLM.ServerError":
case "LLM.ConnectionError":
case "LLM.TimeoutError":
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
return true
case "LLM.Authentication":
case "LLM.PermissionDenied":
case "LLM.NotFound":
case "LLM.QuotaExceeded":
case "LLM.ContentPolicy":
case "LLM.ContextOverflow":
case "LLM.MalformedResponse":
case "LLM.BadRequest":
case "LLM.NoRoute":
case "LLM.APIError":
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error
const exhaustive: never = error.reason
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
return failure.cause.retryAfterMs
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
return undefined
}
+5 -3
View File
@@ -1,6 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
@@ -49,6 +49,7 @@ const make = (dependencies: Dependencies) => {
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return
const chunks: string[] = []
let failed = false
const streamed = yield* dependencies.llm
.stream(
LLM.request({
@@ -60,13 +61,14 @@ const make = (dependencies: Dependencies) => {
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
return Effect.void
}),
Effect.as(true),
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
)
if (!streamed) return
if (!streamed || failed) return
const title = chunks
.join("")
.split("\n")
+25 -34
View File
@@ -1,5 +1,4 @@
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { LLMError, ToolFailure } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
@@ -9,45 +8,37 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error {
if (isLLMError(cause)) {
switch (cause._tag) {
case "LLM.RateLimit":
return { type: "provider.rate-limit", message: cause.message }
case "LLM.Authentication":
return { type: "provider.auth", message: cause.message }
case "LLM.PermissionDenied":
return { type: "provider.auth", message: cause.message }
case "LLM.NotFound":
return { type: "provider.not-found", message: cause.message }
case "LLM.QuotaExceeded":
return { type: "provider.quota", message: cause.message }
case "LLM.ContentPolicy":
return { type: "provider.content-filter", message: cause.message }
case "LLM.ContextOverflow":
return { type: "provider.context-overflow", message: cause.message }
case "LLM.ConnectionError":
return { type: "provider.transport", message: cause.message }
case "LLM.TimeoutError":
return { type: "provider.timeout", message: cause.message }
case "LLM.ServerError":
return { type: "provider.internal", message: cause.message }
case "LLM.MalformedResponse":
return { type: "provider.invalid-output", message: cause.message }
case "LLM.BadRequest":
return { type: "provider.invalid-request", message: cause.message }
case "LLM.NoRoute":
return { type: "provider.no-route", message: cause.message }
case "LLM.APIError":
return { type: "provider.unknown", message: cause.message }
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
default: {
const exhaustive: never = cause
const exhaustive: never = cause.reason
return exhaustive
}
}
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
if (cause instanceof ToolFailure)
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
+1 -1
View File
@@ -35,7 +35,7 @@ Registrations are scoped:
- The latest active same-placement registration wins.
- Closing any registration removes only that registration and reveals the next active one.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
- An invocation captures the effective tool once settlement starts.
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
+14 -10
View File
@@ -36,19 +36,23 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
interface Registration {
export interface Registration {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly group?: string
}
export const create = (registrations: ReadonlyMap<string, Registration>) => {
export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined
}) => {
const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
for (const [name, registration] of registrations) {
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
description: child.description,
@@ -111,8 +115,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const current = options.current(name)
if (!current || current.identity !== registration.identity)
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
const output = yield* settle(
registration.tool,
current.tool,
{ type: "tool-call", id: context.toolCallID, name, input },
{
sessionID: context.sessionID,
@@ -169,12 +176,9 @@ function formatResult(result: CodeMode.Result) {
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
.join("\n")
.trim()
const warnings =
result.ok && result.warnings && result.warnings.length > 0
? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
: undefined
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
return [output, warnings, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
if (!result.logs || result.logs.length === 0) return output
const logs = `Logs:\n${result.logs.join("\n")}`
return output === "" ? logs : `${output}\n\n${logs}`
}
function formatValue(value: CodeMode.DataValue) {
+13
View File
@@ -194,6 +194,19 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("request", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
return
}
delete event.tools.patch
}),
)
}),
}
+16 -20
View File
@@ -22,7 +22,7 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
@@ -86,10 +86,21 @@ export const Plugin = {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
},
fields: [
toField(input.questions[0], 0),
...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
],
mode: "form",
fields: input.questions.map(
(question, index): Form.Field => ({
key: `q${index}`,
title: question.header,
description: question.question,
type: question.multiple === true ? "multiselect" : "string",
options: question.options.map((option) => ({
value: option.label,
label: option.label,
description: option.description,
})),
custom: true,
}),
),
})
.pipe(Effect.orDie),
),
@@ -111,18 +122,3 @@ export const Plugin = {
.pipe(Effect.orDie)
}),
}
function toField(question: QuestionV2.Prompt, index: number): Form.Field {
return {
key: `q${index}`,
title: question.header,
description: question.question,
type: question.multiple === true ? "multiselect" : "string",
options: question.options.map((option) => ({
value: option.label,
label: option.label,
description: option.description,
})),
custom: true,
}
}
+39 -12
View File
@@ -25,7 +25,7 @@ export type ExecuteInput = {
}
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
@@ -33,6 +33,11 @@ export interface Interface {
) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface MaterializeInput {
readonly model: { readonly id: string; readonly provider: string }
readonly permissions?: PermissionV2.Ruleset
}
export interface Materialization {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
@@ -53,6 +58,7 @@ const registryLayer = Layer.effect(
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly group?: string
@@ -128,6 +134,18 @@ const registryLayer = Layer.effect(
}
})
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration || registration.identity !== advertised) {
const message = `Stale tool call: ${input.call.name}`
return {
result: { type: "error" as const, value: message },
error: { type: "tool.stale" as const, message },
}
}
return yield* settleTool(input, registration.tool)
})
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
@@ -146,6 +164,7 @@ const registryLayer = Layer.effect(
{
token,
registration: {
identity: {},
tool: entry.tool,
name: entry.name,
group: entry.group,
@@ -166,20 +185,28 @@ const registryLayer = Layer.effect(
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
const direct = new Map<string, Registration>()
const deferred = new Map<string, Registration>()
const rules = permissions ?? []
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
const registrations = new Map<string, Registration>()
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (registration.deferred) deferred.set(name, registration)
else direct.set(name, registration)
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations) {
if (
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
)
registrations.delete(name)
}
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
const execute =
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
})
: undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
@@ -188,7 +215,7 @@ const registryLayer = Layer.effect(
settle: (input) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
+27
View File
@@ -192,5 +192,32 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("request", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
const selected = yield* agents.resolve(event.agent)
if (!selected) return
const available = (yield* agents.list())
.filter(
(agent) =>
agent.mode !== "primary" &&
!agent.hidden &&
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
)
.toSorted((a, b) => a.id.localeCompare(b.id))
if (available.length === 0) return
tool.description = [
tool.description,
"",
"Available subagents:",
...available.map(
(agent) =>
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
),
].join("\n")
}),
)
}),
}
+58 -5
View File
@@ -9,9 +9,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
@@ -51,16 +54,61 @@ describe("ConfigAgentPlugin.Plugin", () => {
}),
)
it.effect("applies global permissions before built-in and configured agent rules", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
bash: { "rm*": "ask", "git reset*": "ask" },
read: { "secret*": "deny" },
},
}),
),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
)
const build = yield* agents.get(AgentV2.defaultID)
const explore = yield* agents.get(AgentV2.ID.make("explore"))
if (!build || !explore) throw new Error("expected built-in agents")
expect(PermissionV2.evaluate("shell", "rm -rf tmp", build.permissions).effect).toBe("ask")
expect(PermissionV2.evaluate("shell", "rm -rf tmp", explore.permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("shell", "ls", explore.permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("read", "secret.txt", build.permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("read", "secret.txt", explore.permissions).effect).toBe("allow")
}),
)
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) =>
const replacement = AgentV2.ID.make("replacement")
yield* agents.transform((editor) => {
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
}),
)
})
editor.update(replacement, (agent) => {
agent.permissions.splice(0, agent.permissions.length, { action: "bash", resource: "*", effect: "deny" })
})
})
const config = Config.Service.of({
entries: () =>
@@ -110,13 +158,18 @@ describe("ConfigAgentPlugin.Plugin", () => {
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
...defaultPermissions,
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("allow")
expect((yield* agents.get(replacement))?.permissions).toEqual([
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "deny" },
])
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
-40
View File
@@ -254,43 +254,6 @@ describe("Config", () => {
),
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
]),
)
const targets: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => {
targets.push(input)
return Stream.never
},
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
yield* config.entries()
expect(targets).toEqual([
{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) },
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
}),
),
),
)
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -952,11 +915,8 @@ describe("Config", () => {
"global",
AbsolutePath.make(global),
"root",
AbsolutePath.make(path.join(root, "opencode.json")),
"parent",
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
"directory",
AbsolutePath.make(path.join(directory, "opencode.json")),
"root-dot",
AbsolutePath.make(path.join(root, ".opencode")),
"directory-dot",
-26
View File
@@ -63,32 +63,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("disables configured plugins by exported ID", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}),
)
})
it.live("does not disable configured plugins by package target", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
}),
)
})
it.live("loads configured Effect plugins with options", () =>
withLocation(
{
+20 -70
View File
@@ -15,6 +15,7 @@ const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
mode: "form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@@ -46,6 +47,7 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "MCP input",
mode: "form",
fields: [{ key: "name", type: "string", required: true }],
})
expect(created.sessionID).toBe("global")
@@ -57,14 +59,6 @@ describe("Form", () => {
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
const externalOnly = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
})
yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
}),
)
@@ -74,18 +68,15 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "Conditional form",
mode: "form",
fields: [
{ key: "confirm", type: "boolean", required: true },
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
],
})
const inactive = yield* service
.reply({ id: created.id, answer: { confirm: true, reason: "x" } })
.pipe(Effect.flip)
expect(inactive).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }),
)
const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip)
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
expect(missing).toEqual(
@@ -110,6 +101,7 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "Multiselect form",
mode: "form",
fields: [
{ key: "langs", type: "multiselect", options },
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
@@ -132,6 +124,7 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "Dependent form",
mode: "form",
fields: [
{ key: "a", type: "boolean" },
{ key: "b", type: "boolean" },
@@ -149,9 +142,7 @@ describe("Form", () => {
})
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
expect(missingX).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }),
)
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
const inactiveX = yield* service
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
@@ -159,9 +150,7 @@ describe("Form", () => {
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
expect(missingZ).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }),
)
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
@@ -178,6 +167,7 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "Selection form",
mode: "form",
fields: [
{ key: "langs", type: "multiselect", options },
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
@@ -196,9 +186,7 @@ describe("Form", () => {
)
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
expect(inactive).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }),
)
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
@@ -211,6 +199,7 @@ describe("Form", () => {
const created = yield* service.create({
sessionID: "global",
title: "Cascading form",
mode: "form",
fields: [
{ key: "a", type: "boolean" },
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
@@ -231,25 +220,18 @@ describe("Form", () => {
it.effect("rejects invalid when definitions at creation", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const flipCreate = (fields: Form.CreateInput["fields"]) =>
service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip)
expect(
yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
).toEqual(
new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }),
)
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
expect(
yield* flipCreate([
{ key: "a", type: "string" },
{ key: "a", type: "string" },
{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] },
]),
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
expect(
yield* flipCreate([
{ key: "a", type: "external", url: "https://example.com" },
{ key: "a", type: "string" },
{ key: "a", type: "string" },
]),
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
@@ -259,7 +241,9 @@ describe("Form", () => {
{ key: "a", type: "boolean" },
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
]),
).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
).toEqual(
new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
)
expect(
yield* flipCreate([
@@ -274,40 +258,6 @@ describe("Form", () => {
}),
)
it.effect("requires external field acknowledgements", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [
{ key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
{ key: "name", type: "string", required: true },
],
})
const invalidAnswers: ReadonlyArray<Form.Answer> = [
{ name: "Ava" },
{ authorization: false, name: "Ava" },
{ authorization: "yes", name: "Ava" },
]
for (const answer of invalidAnswers) {
expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
new Form.InvalidAnswerError({
id: created.id,
message: "External form field must be acknowledged: authorization",
}),
)
}
yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
expect(yield* service.state(created.id)).toEqual({
status: "answered",
answer: { authorization: true, name: "Ava" },
})
}),
)
it.effect("cleans up created forms when event publication fails", () =>
Effect.gen(function* () {
const service = yield* Form.Service
+12 -6
View File
@@ -13,8 +13,14 @@ export const toolIdentity = {
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
// Default fixture model: a non-OpenAI provider, so edit and write are the materialized edit tools.
export const testModel: ToolRegistry.MaterializeInput["model"] = { id: "claude-test", provider: "anthropic" }
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: PermissionV2.Ruleset,
model = testModel,
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
@@ -70,8 +76,8 @@ export const registerToolPlugin = <R>(plugin: {
yield* plugin.effect(context)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
settleTool(registry, input, model).pipe(Effect.map((settlement) => settlement.result))
+8 -96
View File
@@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -47,9 +47,7 @@ type ResourceTemplatePage = {
nextCursor?: string
}
function resourceServer(
input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
) {
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
return Effect.acquireRelease(
Effect.promise(async () => {
const state = {
@@ -73,42 +71,7 @@ function resourceServer(
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: input.emptyElicitation
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: input.urlElicitation
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: [],
}),
)
if (input.emptyElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
mode: "form",
message: "Confirm",
requestedSchema: { type: "object", properties: {} },
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
}
if (input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
mode: "url",
message: "Authorize access",
url: "https://example.com/authorize",
elicitationId: "elicitation-test",
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
}
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@@ -135,7 +98,6 @@ function resourceServer(
state,
url: http.url.toString(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
close: async () => {
await protocol.close().catch(() => {})
await http.stop(true)
@@ -146,11 +108,10 @@ function resourceServer(
)
}
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
function resourceMcpLayer(url: string) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe(
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
Layer.succeed(
@@ -172,16 +133,14 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, {
subscribe: () => Stream.never,
publish: (definition, data) => {
const event = {
publish: (definition, data) =>
Effect.succeed({
id: EventV2.ID.create(),
type: definition.type,
data,
} as EventV2.Payload<typeof definition>
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
},
} as EventV2.Payload<typeof definition>),
}),
Layer.mock(Form.Service, {}),
Layer.mock(Integration.Service, {
connection: {
active: unusedIntegration,
@@ -531,53 +490,6 @@ test("skips MCP resource requests when the capability is absent", async () => {
)
})
test("accepts empty MCP elicitations without creating forms", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const forms = yield* Form.Service
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
expect(yield* forms.list()).toEqual([])
return result
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
expect(result.structured).toEqual({ action: "accept", content: {} })
}),
),
)
})
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, urlElicitation: true })
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const forms = yield* Form.Service
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
yield* Effect.promise(server.completeElicitation)
const result = yield* Fiber.join(call)
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
return result
}).pipe(
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
)
expect(result.structured).toEqual({ action: "accept" })
}),
),
)
})
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/llm"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
describe("PluginHooks", () => {
it("registers scoped domain hooks and triggers them sequentially", async () => {
const seen: string[] = []
const program = Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")]
}),
)
const event = {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("first")],
messages: [Message.user("original")],
tools: {},
}
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
})
await Effect.runPromise(
Effect.scoped(program).pipe(
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
),
)
})
})
+9 -4
View File
@@ -12,6 +12,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { testModel } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
@@ -254,10 +255,14 @@ describe("PluginV2", () => {
})
yield* plugins.activate([plugin])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
yield* plugins.activate([])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
}),
)
@@ -286,7 +291,7 @@ describe("PluginV2", () => {
yield* plugins.activate([plugin])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
"execute",
@@ -345,7 +350,7 @@ describe("PluginV2", () => {
yield* plugins.activate([plugin])
const materialized = yield* registry.materialize()
const materialized = yield* registry.materialize({ model: testModel })
const settlement = yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"),
+3
View File
@@ -83,6 +83,9 @@ export function host(overrides: Overrides = {}): PluginContext {
prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"),
interrupt: () => Effect.die("unused session.interrupt"),
// Plugins register session hooks during setup, so a bare host accepts the
// registration; the callback only runs when a test triggers the request pipeline.
hook: () => Effect.succeed({ dispose: Effect.void }),
},
}
}
+1 -66
View File
@@ -1,12 +1,9 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Plugin } from "@opencode-ai/plugin/v2"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -96,66 +93,4 @@ describe("fromPromise", () => {
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
}),
)
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const events: string[] = []
const promisePlugin = Plugin.define({
id: "promise-cleanup",
setup: async () => {
events.push("setup")
return async () => {
await Promise.resolve()
events.push("cleanup")
}
},
})
yield* Effect.scoped(
Effect.gen(function* () {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(events).toEqual(["setup"])
}),
)
expect(events).toEqual(["setup", "cleanup"])
}),
)
it.effect("constructs plain Promise tool declarations in the host", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const host = yield* PluginHost.make(plugins)
const promisePlugin = Plugin.define({
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }) => `Hello, ${name}!`,
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
expect(
yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_promise_tool"),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}),
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
}),
)
})
@@ -147,11 +147,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
).toBe(true)
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
+49 -60
View File
@@ -1,56 +1,57 @@
import { describe, expect, test } from "bun:test"
import {
APIError,
Authentication,
BadRequest,
ConnectionError,
ContentPolicy,
ContextOverflow,
MalformedResponse,
AuthenticationReason,
ContentPolicyReason,
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
NoRouteReason,
ModelID,
NoRoute,
NotFound,
PermissionDenied,
ProviderID,
QuotaExceeded,
RateLimit,
RouteID,
ServerError,
TimeoutError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
ToolFailure,
} from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => {
test("maps every LLM error tag to the open wire type", () => {
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
test("maps every LLM reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
"provider.invalid-output",
)
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
expect(
toSessionError(
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
llm(
new NoRouteReason({
route: "route",
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
),
).type,
).toBe("provider.no-route")
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
})
test("preserves the permission rejection type without exposing internal fields", () => {
@@ -63,37 +64,25 @@ describe("toSessionError", () => {
type: "permission.rejected",
message: "Permission denied: external_directory",
})
expect(toSessionError(new Tool.Failure({ message: "failed" }))).toEqual({
type: "tool.execution",
message: "failed",
})
})
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
new RateLimit({ message: "rate" }),
new ServerError({ message: "internal", status: 500 }),
new ConnectionError({ message: "reset" }),
new TimeoutError({ message: "timed out" }),
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
]
const ineligible = [
new Authentication({ message: "auth" }),
new PermissionDenied({ message: "forbidden" }),
new NotFound({ message: "missing" }),
new QuotaExceeded({ message: "quota" }),
new ContentPolicy({ message: "blocked" }),
new ContextOverflow({ message: "too long" }),
new MalformedResponse({ message: "output" }),
new BadRequest({ message: "request" }),
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
new APIError({ message: "unknown" }),
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
llm(new QuotaExceededReason({ message: "quota" })),
llm(new ContentPolicyReason({ message: "blocked" })),
llm(new InvalidProviderOutputReason({ message: "output" })),
llm(new InvalidRequestReason({ message: "request" })),
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
llm(new UnknownProviderReason({ message: "unknown" })),
]
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
})
})
+12 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ConnectionError } from "@opencode-ai/llm"
import { LLMError, TransportReason } from "@opencode-ai/llm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -25,10 +25,17 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
type: "failed",
error: { type: "provider.transport", message: "Disconnected" },
})
expect(
SessionExecution.terminal(
Exit.fail(
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
type: "failed",
@@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { registerToolPlugin, settleTool } from "./lib/tool"
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { LLMEvent } from "@opencode-ai/llm"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
@@ -16,7 +16,7 @@ const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = (providerMetadataKey = "anthropic") => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = {
const events = EventV2.Service.of({
publish: (definition, data) =>
Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
@@ -28,7 +28,16 @@ const capture = (providerMetadataKey = "anthropic") => {
})
return event
}),
}
subscribe: () => Stream.empty,
log: () => Stream.empty,
sequences: () => Effect.succeed(new Map()),
listen: () => Effect.succeed(Effect.void),
project: () => Effect.void,
replay: () => Effect.void,
replayAll: () => Effect.succeed(undefined),
remove: () => Effect.void,
claim: () => Effect.void,
})
return {
published,
publisher: createLLMEventPublisher(events, {
@@ -7,7 +7,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
@@ -52,15 +52,6 @@ const make = (permission?: string) => {
return permission ? Tool.withPermission(tool, permission) : tool
}
const constant = (text: string) =>
Tool.make({
description: "Return text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
})
describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
@@ -91,6 +82,30 @@ describe("ToolRegistry", () => {
}),
)
it.effect("materializes all permission-eligible edit tools before request policy", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
read: make(),
edit: make("edit"),
write: make("edit"),
patch: make("edit"),
})
const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service
.materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
"read",
"edit",
"write",
"patch",
])
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -107,7 +122,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("reuses model definitions across requests", () =>
it.effect("reuses model definitions across provider turns", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
@@ -186,7 +201,7 @@ describe("ToolRegistry", () => {
}),
})
expect(
yield* service.materialize().pipe(
yield* service.materialize({ model: testModel }).pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
@@ -204,7 +219,7 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -330,77 +345,88 @@ describe("ToolRegistry", () => {
}),
)
it.effect("executes the tool advertised in a model request", () =>
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
)
it.effect("rejects a call when its advertised registration was removed", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
const request = yield* service.materialize()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") })
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("reveals the previous registration after an overlay closes", () =>
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: constant("base") })
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({
type: "error",
value: "Stale tool call: first",
})
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
}),
)
it.effect("treats revealing a previous overlay as stale", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const overlay = yield* Scope.make()
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(overlay, Exit.void)
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("executes deferred tools advertised in a model request", () =>
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* service
.register(
{
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
}),
},
{ deferred: true },
)
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register(
{
.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
execute: ({ text }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
}),
},
{ deferred: true },
)
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: make() })
yield* Deferred.succeed(release, undefined)
const settlement = yield* materialized.settle({
...call("execute"),
call: {
type: "tool-call",
id: "call-execute",
name: "execute",
input: { code: 'return await tools.echo({ text: "request" })' },
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(executed).toEqual(["old:request"])
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
}),
)
})
+93 -291
View File
@@ -1,16 +1,14 @@
import { describe, expect, test } from "bun:test"
import {
APIError,
BadRequest,
ConnectionError,
ContextOverflow,
LLMClient,
LLMError,
LLMEvent,
Model,
RateLimit,
ToolFailure,
TransportReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
type LLMError,
type LLMRequest,
} from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
@@ -64,15 +62,14 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
let response: LLMEvent[] = []
let responses: ScriptedResponse[] | undefined
let responses: LLMEvent[][] | undefined
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
let streamGate: Deferred.Deferred<void> | undefined
@@ -95,12 +92,9 @@ const client = Layer.succeed(
responseStream = undefined
return stream
}
const scripted = responses === undefined ? response : (responses.shift() ?? [])
const events = streamFailure
? Stream.fail(streamFailure)
: Array.isArray(scripted)
? Stream.fromIterable(scripted)
: scripted
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
if (!streamGate) return events
return Stream.unwrap(
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
@@ -119,16 +113,6 @@ const reply = {
LLMEvent.finish({ reason: "stop" }),
],
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
textWithUsage: (text: string, id: string, inputTokens: number) =>
fragmentFixture("text", id, [text]).completeEvents.map((event) =>
LLMEvent.is.stepFinish(event)
? LLMEvent.stepFinish({
index: event.index,
reason: event.reason,
usage: { inputTokens, nonCachedInputTokens: inputTokens },
})
: event,
),
tool: (id: string, name: string, input: unknown) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id, name, input }),
@@ -144,11 +128,6 @@ const compactModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const undersizedContextModel = Model.make({
id: "undersized-context",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }),
})
const recoveryModel = Model.make({
id: "recovery",
provider: "fake",
@@ -486,16 +465,26 @@ const setup = Effect.gen(function* () {
return yield* SessionV2.Service
})
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
const providerUnavailable = () =>
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
})
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
const invalidRequest = () =>
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({ message: "Invalid request" }),
})
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
const rateLimited = (retryAfterMs?: number) =>
new LLMError({
module: "test",
method: "stream",
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
})
const setupOverflowRecovery = Effect.gen(function* () {
const session = yield* setup
@@ -794,67 +783,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("executes the tool advertised before a registry reload", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const executions: string[] = []
yield* registry
.register({
reloaded: Tool.make({
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
})
.pipe(Scope.provide(scope))
yield* admit(session, "Use the reloaded tool")
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* Scope.close(scope, Exit.void)
yield* registry.register({
reloaded: Tool.make({
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
})
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
expect(executions).toEqual(["advertised"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use the reloaded tool" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-reloaded",
state: { status: "completed", structured: { value: "advertised" } },
},
],
},
])
}),
)
it.effect("starts a real runner step after default prompt recording", () =>
it.effect("starts a real runner turn after default prompt recording", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -918,7 +847,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries the first request after system context becomes available", () =>
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
@@ -1589,12 +1518,11 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("explains when manual compaction has no history", () =>
it.effect("settles an admitted manual compaction that cannot start", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution should not run")
yield* session.resume(sessionID)
@@ -1603,7 +1531,7 @@ describe("SessionRunnerLLM", () => {
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
error: { message: "Compaction could not start" },
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
@@ -1613,73 +1541,10 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("manually compacts when the model has no context limit", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-unknown-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
requests.length = 0
response = reply.text("Manual summary", "text-manual-unknown-summary")
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])[0]).toContain("Earlier question")
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "Manual summary",
})
}),
)
it.effect("preserves provider errors from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-provider-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "provider.unknown", message: "summary unavailable" },
})
}),
)
it.effect("preserves typed provider failures from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-failure-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
responseStream = Stream.fail(providerUnavailable())
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "provider.transport", message: "Provider unavailable" },
})
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-resolution-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed")
@@ -1702,7 +1567,7 @@ describe("SessionRunnerLLM", () => {
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.textWithUsage("Earlier answer", "text-first", 3_950)
response = reply.text("Earlier answer", "text-first")
yield* admit(session, "Earlier question ".repeat(180))
yield* session.resume(sessionID)
@@ -1710,7 +1575,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
responses = [
reply.text("## Objective\n- Preserve the task", "text-summary"),
reply.textWithUsage("Continued", "text-final", 3_950),
reply.text("Continued", "text-final"),
]
yield* admit(session, "Recent exact request ".repeat(180))
yield* session.resume(sessionID)
@@ -1749,40 +1614,14 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("stops after required automatic compaction fails", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)
yield* admit(session, "Earlier question ".repeat(180))
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
reply.text("Must not run", "text-after-failed-compaction"),
]
yield* admit(session, "Recent exact request ".repeat(180))
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(requests[0]?.generation).toBeUndefined()
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({
type: "compaction",
status: "failed",
reason: "auto",
error: expect.objectContaining({ message: "Unsupported parameter: max_output_tokens" }),
}),
)
}),
)
it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
reply.text("## Objective\n- Recover overflow", "text-summary"),
reply.text("Recovered", "text-final"),
]
@@ -1804,50 +1643,13 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("recovers from provider context overflow without a configured context limit", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = model
responses = [
Stream.fail(contextOverflow()),
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
reply.text("Recovered", "text-final-unknown-limit"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover unknown limit" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("recovers from provider context overflow despite an undersized configured context limit", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = undersizedContextModel
responses = [
Stream.fail(contextOverflow()),
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
reply.text("Recovered", "text-final-undersized-limit"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("persists a second context overflow after one recovery", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
const overflow = () => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
yield* admit(session, "Continue")
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
@@ -1863,7 +1665,16 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responseStream = Stream.fail(contextOverflow())
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
}),
)
responses = [
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
reply.text("Recovered", "text-final"),
@@ -1882,20 +1693,16 @@ describe("SessionRunnerLLM", () => {
it.effect("publishes the original overflow when recovery summarization fails", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* admit(session, "Continue")
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
expect(context).toContainEqual(
expect.objectContaining({
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "provider.unknown", message: "summary unavailable" },
}),
)
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
expect(context.slice(-3)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "compaction", status: "failed", reason: "auto" },
@@ -1907,7 +1714,10 @@ describe("SessionRunnerLLM", () => {
it.effect("interrupts overflow recovery while the summary provider is running", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
reply.text("## Objective\n- Interrupted", "text-summary"),
]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
@@ -2100,7 +1910,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("reloads a model switch before a tool-driven continuation step", () =>
it.effect("reloads a model switch before a tool-driven continuation turn", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@@ -2129,7 +1939,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("restores durable reasoning provider metadata in the next request", () =>
it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Think first")
@@ -2201,7 +2011,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("replays durable provider-executed tool results inline in the next request", () =>
it.effect("replays durable provider-executed tool results inline in a second-turn request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Search first")
@@ -2413,7 +2223,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("steers an active step with newly recorded prompts", () =>
it.effect("steers an active provider turn with newly recorded prompts", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@@ -2633,7 +2443,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("coalesces multiple active steering prompts into one continuation step", () =>
it.effect("coalesces multiple active steering prompts into one continuation turn", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@@ -2660,7 +2470,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs steering input accepted while the active step fails", () =>
it.effect("runs steering input accepted while the active provider turn fails", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@@ -2733,7 +2543,7 @@ describe("SessionRunnerLLM", () => {
id: "call-interrupted",
state: {
status: "error",
error: { type: "aborted", message: "Tool execution interrupted: echo" },
error: { type: "tool.stale", message: "Tool execution interrupted: echo" },
},
},
],
@@ -3297,7 +3107,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("durably fails blocked local tools when a step is interrupted", () =>
it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt blocked tool")
@@ -3353,7 +3163,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a blocked step without local tool execution", () =>
it.effect("interrupts a blocked provider turn without local tool execution", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt provider")
@@ -3490,10 +3300,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail durably")
responseStream = failingResponse(
[LLMEvent.stepStart({ index: 0 })],
new APIError({ message: "Provider unavailable" }),
)
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3510,7 +3317,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail before step")
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3598,15 +3405,13 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail after output")
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
],
contextOverflow(),
)
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
expect(requests).toHaveLength(1)
@@ -3764,13 +3569,11 @@ describe("SessionRunnerLLM", () => {
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
],
new APIError({ message: "Provider unavailable" }),
)
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(toolExecutionsStarted)
@@ -3797,10 +3600,11 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail hosted tool durably")
responseStream = failingResponse(
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
new APIError({ message: "Provider unavailable" }),
)
response = [
LLMEvent.stepStart({ index: 0 }),
hostedCall("call-hosted-provider-error", "effect"),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3827,13 +3631,11 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Defect while provider fails")
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
],
new APIError({ message: "Provider unavailable" }),
)
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
-47
View File
@@ -1,47 +0,0 @@
import { expect, test } from "bun:test"
import { ExecuteTool } from "@opencode-ai/core/tool/execute"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Schema } from "effect"
test("execute preserves successful results with visible unhandled rejections", async () => {
const child = Tool.make({
description: "Always fail",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })),
})
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]]))
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: `tools.fail({}); return "done"` },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_execute"),
toolCallID: "call_execute",
},
),
)
expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
expect(result.content).toEqual([
{
type: "text",
text: [
"done",
"",
"Warnings:",
"- [ToolFailure] Unhandled rejection from an un-awaited promise: Lookup refused",
].join("\n"),
},
])
})
+15 -1
View File
@@ -135,6 +135,9 @@ const call = (patchText: string, id = "call-patch") => ({
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
// patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) =>
Effect.promise(() =>
fs.stat(target).then(
@@ -158,12 +161,15 @@ describe("PatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"patch",
])
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
model,
)
expect(settled.result).toEqual({
type: "text",
@@ -233,6 +239,7 @@ describe("PatchTool", () => {
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
model,
),
).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@@ -260,6 +267,7 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@@ -296,6 +304,7 @@ describe("PatchTool", () => {
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@@ -327,6 +336,7 @@ describe("PatchTool", () => {
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@@ -351,6 +361,7 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
@@ -376,6 +387,7 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
@@ -403,6 +415,7 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.exit),
),
).toBe(true)
@@ -434,6 +447,7 @@ describe("PatchTool", () => {
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
+7 -23
View File
@@ -18,15 +18,6 @@ let captured: Form.CreateInput | undefined
let reject = false
let deny = false
const capturedInput = () => captured
const questionInput = {
questions: [
{
question: "Continue?",
header: "Continue",
options: [{ label: "Yes", description: "Continue" }],
},
],
}
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
@@ -88,7 +79,7 @@ const it = testEffect(
)
describe("QuestionTool", () => {
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
it.effect("omits a denied built-in question and terminally settles a stale call", () =>
Effect.gen(function* () {
captured = undefined
deny = true
@@ -99,7 +90,7 @@ describe("QuestionTool", () => {
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({
result: { type: "error", value: "Permission denied: question" },
@@ -167,6 +158,7 @@ describe("QuestionTool", () => {
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [
{
key: "q0",
@@ -207,22 +199,14 @@ describe("QuestionTool", () => {
yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
fields: [
{
key: "q0",
title: "Continue",
description: "Continue?",
options: [{ value: "Yes", label: "Yes", description: "Continue" }],
custom: true,
type: "string",
},
],
mode: "form",
fields: [],
})
}),
)
@@ -236,7 +220,7 @@ describe("QuestionTool", () => {
const fiber = yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}).pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber)
+4 -2
View File
@@ -27,7 +27,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@@ -146,7 +146,9 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
SubagentTool.name,
)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
-24
View File
@@ -1,24 +0,0 @@
---
title: "Build"
description: "Build on the engine used by millions daily."
mode: "wide"
---
<CardGroup cols={1}>
<Card title="Extend OpenCode" href="/build/plugins">
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
intact.
</Card>
<Card title="Run it as a server" href="/build/client">
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
experience around it.
</Card>
<Card title="Embed it" href="/build/sdk">
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
around it.
</Card>
</CardGroup>
<Warning>
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
</Warning>
@@ -16,15 +16,22 @@ network. Its types and methods are generated from the same contract as the
## Install
```sh
bun add @opencode-ai/client@next
bun add @opencode-ai/client
```
## Create a client
The package has two entrypoints:
- `@opencode-ai/client/promise` uses `fetch` and returns Promises or async
iterables. It has no Effect runtime dependency.
- `@opencode-ai/client/effect` returns Effects and Streams, decodes values into
the V2 schema types, and requires an `HttpClient` service from Effect.
## Promise client
Create a client with the server URL, then call methods grouped by API resource:
```ts
import { OpenCode } from "@opencode-ai/client"
import { OpenCode } from "@opencode-ai/client/promise"
const client = OpenCode.make({
baseUrl: "http://localhost:4096",
@@ -40,28 +47,11 @@ await client.session.prompt({
})
```
## Headers and requests
Pass default authentication or application headers to `OpenCode.make` with
`headers`. You can also supply a custom `fetch` implementation. Each operation
accepts request options as its final argument for an `AbortSignal` or
per-request headers.
```ts
const client = OpenCode.make({
baseUrl: "https://opencode.example.com",
headers: {
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
},
})
await client.session.list(undefined, {
signal: AbortSignal.timeout(10_000),
})
```
## Stream events
Streaming endpoints return async iterables:
```ts
@@ -70,17 +60,11 @@ for await (const event of client.event.subscribe()) {
}
```
## Effect
## Effect client
OpenCode provides a first-class Effect client through the
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
and decodes responses into OpenCode schema values.
```sh
bun add @opencode-ai/client@next effect
```
### Create a client
Install the `effect` peer dependency when using the Effect entrypoint. The
client uses canonical V2 values such as `Location.Ref` and `Session.ID`, and
returns typed failures in the Effect error channel.
```ts
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
@@ -105,41 +89,3 @@ const session = await Effect.runPromise(
Streaming operations, including `client.event.subscribe()` and
`client.session.log(...)`, return Effect `Stream` values.
### Service
`Service` discovers and manages the local OpenCode background service from a
Node application:
- `Service.discover()` returns a healthy registered endpoint without starting
a process.
- `Service.start()` reuses a compatible service or starts one when needed.
- `Service.stop()` stops the registered service.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```sh
bun add @effect/platform-node
```
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, Service } from "@opencode-ai/client/effect"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const endpoint = yield* Service.start()
const client = yield* OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
return yield* client.health.get()
})
const health = await Effect.runPromise(
program.pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provide(NodeFileSystem.layer),
),
)
```
+1 -1
View File
@@ -421,7 +421,7 @@ accepts options.
}
```
See the [plugins guide](/build/plugins) for plugin development and configuration.
See the [plugins guide](/plugins) for plugin development and configuration.
### Providers
+6 -1
View File
@@ -46,7 +46,12 @@
},
{
"tab": "Build",
"pages": ["build/index", "build/plugins", "build/client", "build/sdk"]
"groups": [
{
"group": "Build with OpenCode",
"pages": ["plugins", "client", "sdk/index"]
}
]
},
{
"tab": "API",
+1 -1
View File
@@ -501,7 +501,7 @@ plugin API is still being finalized during beta, and detailed plugin migration g
ready.
Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related
local modules and dependencies together. See the current beta [Plugins guide](/build/plugins).
local modules and dependencies together. See the current beta [Plugins guide](/plugins).
## Server API and clients
@@ -5,19 +5,20 @@ description: "Extend OpenCode with plugins."
Plugins extend OpenCode in-process. They can transform agents, models, commands,
integrations, references, skills, and tools; intercept model requests and tool
execution; and call a subset of the V2 client.
execution; and call a location-scoped subset of the V2 client.
<Warning>
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration
may change before the stable release. Use the `/v2` exports described on this
page.
may change before the stable release. Use only the `/v2` exports described on
this page; the root `@opencode-ai/plugin` API is the legacy API.
</Warning>
## Load plugins
Plugins can be loaded from npm packages, explicit local paths, or config
directories. Each module must have one default export containing a unique
plugin `id` and a `setup` function.
plugin `id` and either a Promise `setup` function or an Effect `effect`
function.
### Configuration
@@ -74,25 +75,26 @@ relative config entry.
### Enable and disable
A string beginning with `-` disables plugins by their exported `id`. `*`
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
A string beginning with `-` removes a previously selected target. `*` matches
everything, and a suffix of `.*` matches an ID or target prefix. Directives are
applied in order:
```jsonc title="opencode.jsonc"
{
"plugins": [
"./plugins/reviewer.ts",
"-acme.reviewer",
"-opencode.provider.*",
"opencode.provider.openai"
"opencode.provider.openai",
"-./plugins/old.ts",
"-*",
"./plugins/only-this-one.ts"
]
}
```
Package specifiers and local paths locate plugin modules; they are not disable
selectors. Use the `id` from the plugin's default export to disable it. A later
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
after local auto-discovery, so they can disable discovered plugins by ID.
Use the same package specifier or resolved local target to remove an external
plugin. Built-in and embedded plugins can be selected by their plugin ID.
Explicit config directives run after local auto-discovery, so they can disable
discovered plugins.
User plugins are activated in configured order between OpenCode's internal
plugin phases. Hooks run sequentially in registration order, and later hooks
@@ -112,10 +114,12 @@ visible from the plugin file, for example:
```sh
cd .opencode
bun add @opencode-ai/plugin@next
bun add @opencode-ai/plugin@1.17.15 effect@4.0.0-beta.83
```
Match the plugin package version to the OpenCode release you target.
`effect` is required for Effect plugins and for the `Schema` values used by
typed tools. A Promise plugin that does not define tools may only need
`@opencode-ai/plugin`. Match these versions to the OpenCode release you target.
Configuration and discovered plugin files under watched config directories are
reloaded when they change. Reloading replaces the active plugin generation and
@@ -124,7 +128,8 @@ package version or a local dependency when no watched file changed.
## Create a plugin
Export the result of `Plugin.define` as the module default:
The Promise API is the simplest option. Export the result of `Plugin.define`
as the module default:
```ts title=".opencode/plugins/reviewer.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
@@ -147,33 +152,42 @@ export default Plugin.define({
})
```
`setup` runs each time the plugin is activated. Register long-lived behavior
during setup; do not wait there on an infinite event stream. It may return a
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
when the plugin is disabled, reloaded, or shut down:
`setup` runs each time the plugin is activated for a Location. Register
long-lived behavior during setup; do not wait there on an infinite event
stream.
```ts
setup: async (ctx) => {
const controller = new AbortController()
const task = synchronize(ctx, controller.signal)
### Effect plugins
return async () => {
controller.abort()
await task
}
}
Use the Effect entrypoint when the implementation benefits from Effect
composition, fibers, or scoped resources:
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
Hook registrations are released automatically with the same plugin scope. Use
the returned cleanup for resources the plugin owns, such as timers, watchers,
connections, and background tasks.
The plugin effect is scoped. Finalizers, scoped fibers, and registrations are
released when the plugin reloads or unloads. OpenCode deliberately isolates the
effect from its private Core services; use only the public `ctx` capabilities.
### Context
## Context
The plugin context is essentially an [OpenCode server client](/build/client).
Its read and action methods use the same inputs and responses as the client. It
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
and plugin options.
Promise methods return Promises; the equivalent Effect methods return
`Effect`. Read and action methods use the same inputs and location-aware
responses as the V2 client APIs.
| Capability | Available operations |
| --- | --- |
@@ -192,11 +206,16 @@ and plugin options.
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
Unlike the legacy API, V2 does not provide `$`, `directory`, `worktree`, or a
general SDK client on the context. A plugin is Location-scoped, and the exposed
domain clients apply that Location by default.
### Transform hooks
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
or remove definitions, override settings, choose defaults, and provide tools or
other sources.
Transforms synchronously edit a draft whenever a stateful domain is built.
Registering or disposing a transform rebuilds the domain from fresh state and
runs all active transforms in order. Call the domain's `reload()` method when
external data captured by a transform changes.
| Transform | Draft operations |
| --- | --- |
@@ -208,42 +227,10 @@ other sources.
| `skill.transform` | `source`, `list` |
| `tool.transform` | `add` |
Here's an example that keeps models synced from a remote source:
```js title=".opencode/plugins/remote-models.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.remote-models",
setup: async (ctx) => {
let models = []
await ctx.catalog.transform((catalog) => {
for (const model of models) {
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
}
})
const refresh = async () => {
const response = await fetch("https://example.com/opencode/models.json", {
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) return
models = await response.json()
await ctx.catalog.reload()
}
await refresh()
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
return () => clearInterval(timer)
},
})
```
`ctx.catalog.reload()` replays every catalog transform to derive the new
catalog. Each plugin's logic remains composed with the others, so a later
plugin can still modify models added by an earlier one. The catalog updates
without restarting OpenCode.
Hook registrations are owned by the plugin scope. Transform and runtime hook
calls also return a `Registration` with `dispose` for explicit cleanup. Tool
contributions currently remain until the owning plugin scope closes, so prefer
scope cleanup for plugin-wide teardown while this API is beta.
### Runtime hooks
@@ -282,39 +269,27 @@ export default Plugin.define({
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
handle expected errors inside the callback.
## Examples
## Add a tool
### Add a tool
Use `Tool.make` with Effect schemas. Promise tools use async executors:
Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
use an async executor:
```js title=".opencode/plugins/greeting.js"
```ts title=".opencode/plugins/greeting.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
import { Schema } from "effect"
const greeting = Tool.make({
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }) => `Hello, ${name}!`,
})
export default Plugin.define({
id: "acme.greeting",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "greeting",
description: "Create a greeting",
jsonSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
structured: { greeting: text },
content: [{ type: "text", text }],
}
},
})
tools.add("greeting", greeting)
})
},
})
@@ -322,48 +297,38 @@ export default Plugin.define({
Unsupported characters in tool and group names are normalized to underscores.
The resulting exposed key must begin with a letter and contain at most 64
letters, digits, underscores, or hyphens. Set `options` on the declaration to
configure registration with `{ group, deferred }`:
letters, digits, underscores, or hyphens. `tools.add` also accepts
`{ group, deferred }`:
- `group` prefixes and groups the exposed tool name.
- `deferred: true` makes the tool available through the deferred `execute`
tool instead of exposing it directly.
The executor receives a second context argument containing `sessionID`,
`agent`, `assistantMessageID`, and `toolCallID`.
`agent`, `assistantMessageID`, and `toolCallID`. Use
`Tool.withPermission(tool, "permission-name")` to assign a permission key.
Effect plugins import the helper from `@opencode-ai/plugin/v2/effect/tool` and
return an `Effect` from `execute`.
### Add a command
## Types
```js title=".opencode/plugins/review-command.js"
import { Plugin } from "@opencode-ai/plugin/v2"
`Plugin.define` infers the context and callbacks. The Promise root also
re-exports the canonical `Agent`, `Command`, `Connection`, `Credential`,
`Integration`, `Model`, `Provider`, `Reference`, and `Skill` schema namespaces.
Import narrower API types from their public subpaths when needed:
export default Plugin.define({
id: "acme.review-command",
setup: async (ctx) => {
await ctx.command.transform((commands) => {
commands.update("review", (command) => {
command.description = "Review the current changes"
command.template = "Review the current changes for correctness and missing tests."
})
})
},
})
```ts
import { Plugin, Model } from "@opencode-ai/plugin/v2"
import type { Context } from "@opencode-ai/plugin/v2/plugin"
import type { AgentDraft } from "@opencode-ai/plugin/v2/agent"
import type { ToolExecuteBeforeEvent } from "@opencode-ai/plugin/v2/tool"
```
### Set the default model
```js title=".opencode/plugins/default-model.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.default-model",
setup: async (ctx) => {
await ctx.catalog.transform((catalog) => {
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
})
},
})
```
Effect equivalents live below `@opencode-ai/plugin/v2/effect`, such as
`@opencode-ai/plugin/v2/effect/plugin` and
`@opencode-ai/plugin/v2/effect/tool`. Avoid importing types or runtime values
from `@opencode-ai/core` or `@opencode-ai/server`; those are private host
implementation details.
## Publish a package
@@ -377,7 +342,8 @@ manifest is:
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@opencode-ai/plugin": "next"
"@opencode-ai/plugin": "1.17.15",
"effect": "4.0.0-beta.83"
}
}
```
@@ -389,7 +355,7 @@ change.
## Verify loading
List active plugin IDs through the V2 API:
List active plugin IDs for the current Location through the V2 API:
```sh
opencode2 api get /api/plugin
@@ -399,40 +365,3 @@ If a plugin is absent, check the server log described in
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
logged; one failing package does not prevent unrelated valid packages from
being resolved.
## Effect
OpenCode provides a first-class Effect API for plugins through the
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
plugin package and export an `effect` function instead of `setup`:
```sh
bun add @opencode-ai/plugin@next effect
```
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
Context operations return Effects. The plugin effect is scoped, so finalizers,
fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect` and the contracts exported from
`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may
fail with the typed tool failure channel.
@@ -4,7 +4,7 @@ description: "Embed an OpenCode host in an Effect application."
---
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
host OpenCode in-process. Unlike the [network client](/client), it assembles the
OpenCode server and routes API calls through its HTTP router in memory. It opens
no HTTP listener and adds no network hop between the client and server.
@@ -72,4 +72,4 @@ const active = await Effect.runPromise(
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
use the same discovery and location-scoped activation path as configured
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
[Plugins guide](/plugins) for the plugin shape and available hooks.
+3 -3
View File
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
@@ -291,7 +291,7 @@ Use this order for every protocol module:
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
+48 -47
View File
@@ -6,53 +6,54 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A
## Existing Status Sources
| File | What it tracks | Limitation |
| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `packages/llm/DESIGN.md` | Future clean-break API proposal, currently named `@opencode-ai/ai` in the draft. | Not a provider parity tracker. |
| `packages/llm/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
| File | What it tracks | Limitation |
| --- | --- | --- |
| `packages/llm/DESIGN.md` | Future clean-break API proposal, currently named `@opencode-ai/ai` in the draft. | Not a provider parity tracker. |
| `packages/llm/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
| `specs/v2/provider-model.md` | V2 catalog endpoint schema and current Session runner adaptation surface. | Runner-specific; not a native LLM package status matrix. |
## Current Implementation Snapshot
| Native slice | Source | Current state | Main gaps |
| ---------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | No OpenAI-compatible Responses protocol/facade. Family quirks are mostly endpoint defaults, not full typed behavior. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
| Native slice | Source | Current state | Main gaps |
| --- | --- | --- | --- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | No OpenAI-compatible Responses protocol/facade. Family quirks are mostly endpoint defaults, not full typed behavior. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
## V2 Runner Status
`packages/core/src/session/runner/model.ts` currently resolves only this native subset from catalog `aisdk` metadata:
| Catalog API | Native route used today |
| --------------------------------------------------- | ---------------------------- |
| `aisdk:@ai-sdk/openai` | `OpenAIResponses.route` |
| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` |
| Catalog API | Native route used today |
| --- | --- |
| `aisdk:@ai-sdk/openai` | `OpenAIResponses.route` |
| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` |
| `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` |
Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` when the V2 native runner tries to resolve it. This includes `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/google-vertex/anthropic`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, and `@ai-sdk/amazon-bedrock/mantle`.
## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat plus future Responses | Partial | Add OpenAI-compatible Responses. Decide per-family namespace/profile behavior for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Missing | Implement Vertex Anthropic endpoint/auth selection, regional endpoint behavior, and compatibility with Anthropic Messages lowering/parsing. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
| AI SDK package | Intended native target | Status | Biggest gaps |
| --- | --- | --- | --- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat plus future Responses | Partial | Add OpenAI-compatible Responses. Decide per-family namespace/profile behavior for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Missing | Implement Vertex Anthropic endpoint/auth selection, regional endpoint behavior, and compatibility with Anthropic Messages lowering/parsing. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
## Highest-Risk Gaps
@@ -70,20 +71,20 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh
These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose |
| --------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. |
| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | Missing | Vertex Gemini API. |
| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. |
| API slice | Package-like entrypoint | Purpose |
| --- | --- | --- |
| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. |
| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | Missing | Vertex Gemini API. |
| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices
+1 -1
View File
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type {
RouteModelInput,
RouteRoutedModelInput,
+14 -6
View File
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
import {
GenerationOptions,
HttpOptions,
MalformedResponse,
type LLMError,
InvalidProviderOutputReason,
LLMError,
LLMEvent,
LLMRequest,
LLMResponse,
@@ -121,14 +121,22 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
)
if (!call || !LLMEvent.is.toolCall(call))
return yield* new MalformedResponse({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
return yield* new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
}),
})
const object = yield* tool._decode(call.input).pipe(
Effect.mapError(
(error) =>
new MalformedResponse({
message: `generateObject: tool input failed schema decode: ${error.message}`,
new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: tool input failed schema decode: ${error.message}`,
}),
}),
),
)
@@ -19,7 +19,7 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { classifyApiFailure } from "../provider-error"
import { isContextOverflow } from "../provider-error"
import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -832,11 +832,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
return message || type || "Anthropic Messages stream error"
}
const onError = (event: AnthropicEvent) =>
classifyApiFailure({
message: providerErrorMessage(event),
code: event.error?.type,
})
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[
LLMEvent.providerError({
message: providerErrorMessage(event),
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
}),
],
]
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
@@ -844,7 +848,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "error") return Effect.fail(onError(event))
if (event.type === "error") return Effect.succeed(onError(state, event))
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+21 -14
View File
@@ -17,7 +17,7 @@ import {
type ToolResultPart,
} from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream"
import { classifyApiFailure } from "../provider-error"
import { isContextOverflow } from "../provider-error"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
@@ -586,20 +586,27 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
}
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
const message =
event.internalServerException?.message ??
event.modelStreamErrorException?.message ??
event.serviceUnavailableException?.message ??
"Bedrock Converse stream error"
return [state, [LLMEvent.providerError({ message })]] as const
}
if (event.validationException || event.throttlingException) {
const message =
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
return [
state,
[
LLMEvent.providerError({
message,
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
}),
],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* classifyApiFailure({
message: exception[1]?.message ?? "Bedrock Converse stream error",
code: exception[0],
})
}
return [state, []] as const
+23 -10
View File
@@ -19,7 +19,7 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { classifyApiFailure } from "../provider-error"
import { isContextOverflow } from "../provider-error"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure that fails the stream
// with a classified `LLMError`. All three end the stream — kept in one set so
// `step` and the protocol's `terminal` predicate stay in sync.
// `finish` event; `response.failed` is a hard failure that emits a
// `provider-error`. All three end the stream — kept in one set so `step` and
// the protocol's `terminal` predicate stay in sync.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -907,11 +907,24 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
return message || code || fallback
}
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
classifyApiFailure({
message: providerErrorMessage(event, fallback),
code: event.code || event.error?.code || event.response?.error?.code || undefined,
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
return LLMEvent.providerError({
message,
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
})
}
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[providerError(event, "OpenAI Responses response failed")],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[providerError(event, "OpenAI Responses stream error")],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
@@ -937,8 +950,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
if (event.type === "error") return Effect.succeed(onError(state, event))
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+16 -6
View File
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
BadRequest,
MalformedResponse,
type LLMError,
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
type ContentPart,
type LLMRequest,
type MediaPart,
@@ -88,7 +88,11 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
}
export const eventError = (route: string, message: string, raw?: string) =>
new MalformedResponse({ route, message, raw })
new LLMError({
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
})
export const parseJson = (route: string, input: string, message: string) =>
Effect.try({
@@ -248,9 +252,15 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* Canonical invalid-request constructor. Lift one-line `const invalid =
* (message) => invalidRequest(message)` aliases out of every
* route so the error constructor lives in one place. If we ever extend
* `BadRequest` with route context or trace metadata, the change lands here.
* `InvalidRequestReason` with route context or trace metadata, the change
* lands here.
*/
export const invalidRequest = (message: string) => new BadRequest({ message })
export const invalidRequest = (message: string) =>
new LLMError({
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
})
export const matchToolChoice = <Auto, None, Required, Tool>(
route: string,
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
}
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
isLLMError(result)
result instanceof LLMError
/**
* Register a tool call whose start event arrived before any argument deltas.
+6 -115
View File
@@ -1,19 +1,5 @@
import {
APIError,
Authentication,
BadRequest,
ContentPolicy,
ContextOverflow,
HttpContext,
HttpRateLimitDetails,
NotFound,
PermissionDenied,
ProviderMetadata,
QuotaExceeded,
RateLimit,
ServerError,
type LLMError,
} from "./schema"
import { Schema } from "effect"
import { LLMError, ProviderErrorEvent } from "./schema"
const patterns = [
/prompt is too long/i,
@@ -41,102 +27,7 @@ const patterns = [
export const isContextOverflow = (message: string) =>
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
overloaded_error: serverError,
api_error: serverError,
server_error: serverError,
internal_error: serverError,
server_is_overloaded: serverError,
internalServerException: serverError,
serviceUnavailableException: serverError,
modelStreamErrorException: serverError,
rate_limit_error: rateLimit,
rate_limit_exceeded: rateLimit,
too_many_requests: rateLimit,
throttlingException: rateLimit,
authentication_error: (_input, common) => new Authentication(common),
permission_error: (_input, common) => new PermissionDenied(common),
not_found_error: (_input, common) => new NotFound(common),
invalid_request_error: (_input, common) => new BadRequest(common),
invalid_prompt: (_input, common) => new BadRequest(common),
validationException: (_input, common) => new BadRequest(common),
}
export interface ApiFailure {
readonly message: string
readonly status?: number | undefined
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
readonly code?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly requestID?: string | undefined
readonly http?: HttpContext | undefined
readonly providerMetadata?: ProviderMetadata | undefined
}
type CommonFields = {
readonly message: string
readonly status: number | undefined
readonly code: string | undefined
readonly requestID: string | undefined
readonly http: HttpContext | undefined
readonly providerMetadata: ProviderMetadata | undefined
}
function serverError(input: ApiFailure, common: CommonFields) {
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
}
function rateLimit(input: ApiFailure, common: CommonFields) {
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
}
/**
* One classifier for every failure a remote API deliberately reports.
* Protocols call it with in-stream error payloads, the request executor with
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
* three surfaces produce identical `LLMError` tags.
*
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
* HTTP status, provider code, then the generic `APIError` fallback.
*/
export const classifyApiFailure = (input: ApiFailure): LLMError => {
const common: CommonFields = {
message: input.message,
status: input.status,
code: input.code,
requestID: input.requestID,
http: input.http,
providerMetadata: input.providerMetadata,
}
const body = input.http?.body ?? ""
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
if (
clientScoped &&
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
isContextOverflow(input.message) ||
(body.length > 0 && isContextOverflow(body)))
)
return new ContextOverflow(common)
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
if (input.status === 401) return new Authentication(common)
if (input.status === 403) return new PermissionDenied(common)
if (input.status === 404) return new NotFound(common)
if (input.status === 429) {
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
return rateLimit(input, common)
}
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
return new BadRequest(common)
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
if (byCode) return byCode(input, common)
return new APIError(common)
}
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
+11 -4
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -135,9 +135,16 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
}
const toLLMError = (error: AuthError): LLMError => {
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
if (error instanceof Config.ConfigError)
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new LLMError({
module: "Auth",
method: "apply",
reason:
error instanceof MissingCredentialError
? new AuthenticationReason({ message: error.message, kind: "missing" })
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
})
}
return error
}
+3 -35
View File
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
import {
GenerationOptions,
HttpOptions,
isLLMError,
LLMRequest,
LLMResponse,
Model,
ModelLimits,
LLMError as LLMErrorClass,
PreparedRequest,
ProviderID,
mergeGenerationOptions,
@@ -225,39 +225,10 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed !== undefined && isLLMError(failed)) return failed
if (failed instanceof LLMErrorClass) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
/**
* Terminal contract for every route, native or synthetic: a successful
* stream emits exactly one `finish`, and nothing after it. EOF before
* `finish` means the provider stream was truncated (proxy cut, silent
* drop) and must fail rather than let a partial response settle as
* complete. Applied after protocol parsing so `stream.onHalt` flushes are
* still subject to it.
*/
const enforceTerminal = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => {
let finished = false
return events.pipe(
Stream.mapEffect((event) => {
if (finished)
return Effect.fail(
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal finish event`),
)
if (event.type === "finish") finished = true
return Effect.succeed(event)
}),
Stream.concat(
Stream.suspend(() =>
finished
? Stream.empty
: Stream.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
),
),
)
}
function makeFromTransport<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> {
@@ -412,10 +383,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const route = `${compiled.request.model.provider}/${compiled.route.id}`
return compiled.route
.streamPrepared(compiled.prepared, compiled.request, runtime)
.pipe(enforceTerminal(route))
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
+92 -46
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { Cause, Context, Effect, Layer } from "effect"
import {
FetchHttpClient,
Headers,
@@ -8,15 +8,21 @@ import {
HttpClientResponse,
} from "effect/unstable/http"
import {
ConnectionError,
AuthenticationReason,
ContentPolicyReason,
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
TimeoutError,
type LLMError,
InvalidRequestReason,
LLMError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
} from "../schema"
import { classifyApiFailure } from "../provider-error"
import { isContextOverflow } from "../provider-error"
export interface Interface {
readonly execute: (
@@ -79,6 +85,8 @@ const requestId = (headers: Record<string, string>) => {
)
}
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
@@ -211,21 +219,56 @@ const responseHttp = (input: {
rateLimit: input.rateLimit,
})
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// Provider machine code from a JSON error body (`error.code` / `error.type`),
// fed to the shared classifier so code-based rules (overflow, quota) work on
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
const providerCode = (body: string | undefined) => {
if (!body) return undefined
const decoded = Option.getOrUndefined(decodeBodyJson(body))
if (typeof decoded !== "object" || decoded === null) return undefined
const error = (decoded as Record<string, unknown>).error
if (typeof error !== "object" || error === null) return undefined
const fields = error as Record<string, unknown>
if (typeof fields.code === "string") return fields.code
if (typeof fields.type === "string") return fields.type
return undefined
const statusReason = (input: {
readonly status: number
readonly message: string
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http: HttpContext
}) => {
const body = input.http.body ?? ""
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
return new ContentPolicyReason({ message: input.message, http: input.http })
}
if (input.status === 401) {
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
}
if (input.status === 403) {
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
}
if (input.status === 429) {
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
return new QuotaExceededReason({ message: input.message, http: input.http })
}
return new RateLimitReason({
message: input.message,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
http: input.http,
})
}
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
) {
return new InvalidRequestReason({
message: input.message,
classification: isContextOverflow(body) ? "context-overflow" : undefined,
http: input.http,
})
}
if (input.status >= 500 || providerInternalStatus(input.status)) {
return new ProviderInternalReason({
message: input.message,
status: input.status,
retryAfterMs: input.retryAfterMs,
http: input.http,
})
}
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
}
const statusError =
@@ -238,55 +281,58 @@ const statusError =
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
return yield* classifyApiFailure({
status: response.status,
message: providerMessage(response.status, details),
code: providerCode(details.body),
retryAfterMs: retryAfter,
rateLimit,
requestID: requestId(headers),
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
return yield* new LLMError({
module: "RequestExecutor",
method: "execute",
reason: statusReason({
status: response.status,
message: providerMessage(response.status, details),
retryAfterMs: retryAfter,
rateLimit,
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit,
}),
}),
})
})
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
const connectionError = (input: {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
new ConnectionError({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: httpContext(input.request),
cause: error,
new LLMError({
module: "RequestExecutor",
method: "execute",
reason: new TransportReason({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
}),
})
if (Cause.isTimeoutError(error)) {
return new TimeoutError({ message: error.message })
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return connectionError({ message: "HTTP transport failed" })
return transportError({ message: "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
return connectionError({
return transportError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
request,
})
}
return connectionError({
return transportError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
request,
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { ConnectionError, type LLMError } from "../../schema"
import { LLMError, TransportReason } from "../../schema"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -27,10 +27,15 @@ type WebSocketConstructorWithHeaders = new (
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
const transportError = (
_method: string,
method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
) => new ConnectionError({ message, url: input.url, kind: input.kind })
) =>
new LLMError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
})
const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message
+81 -110
View File
@@ -1,6 +1,9 @@
import { Schema } from "effect"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
@@ -28,150 +31,118 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
/**
* Fields shared by every failure the remote API deliberately reported
* whether as a non-2xx response, an SSE error event, a WebSocket error
* message, or a binary exception frame. `status` is absent when the error
* arrived mid-stream without an HTTP status; `code` carries the provider's
* machine-readable error code (e.g. `context_length_exceeded`) when one
* exists.
*/
const apiFailureFields = {
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
message: Schema.String,
status: Schema.optional(Schema.Number),
code: Schema.optional(Schema.String),
requestID: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
}
}
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
...apiFailureFields,
parameter: Schema.optional(Schema.String),
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
_tag: Schema.tag("Authentication"),
message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
/** Credentials are missing, invalid, or expired (401). */
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
...apiFailureFields,
}) {}
/** Authenticated but not allowed (403). */
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
...apiFailureFields,
}) {}
/** Model or endpoint does not exist (404). */
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
...apiFailureFields,
}) {}
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
...apiFailureFields,
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
_tag: Schema.tag("RateLimit"),
message: Schema.String,
retryAfterMs: Schema.optional(Schema.Number),
rateLimit: Schema.optional(HttpRateLimitDetails),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
...apiFailureFields,
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
/** Provider refused the content for policy/safety reasons. */
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
...apiFailureFields,
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
/**
* The request exceeds the model's context window. Designated tag because
* Core recovers from it structurally (compaction) rather than surfacing it.
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
*/
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
...apiFailureFields,
}) {}
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
...apiFailureFields,
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"),
message: Schema.String,
status: Schema.Number,
retryAfterMs: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
...apiFailureFields,
}) {}
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
kind: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
cause: Schema.optional(Schema.Defect()),
}) {}
/** The request or stream read timed out before the provider answered. */
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
message: Schema.String,
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
/**
* Transport succeeded but the content broke the protocol contract:
* undecodable frames, premature EOF without a terminal `finish`, duplicate
* terminals, or output after a terminal event.
*/
export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"LLM.Error.InvalidProviderOutput",
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
/** Request construction failed locally: the selected model resolves to no executable route. */
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
route: RouteID,
provider: ProviderID,
model: ModelID,
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"),
message: Schema.String,
status: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export const LLMErrorReason = Schema.Union([
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
RateLimitReason,
QuotaExceededReason,
ContentPolicyReason,
ProviderInternalReason,
TransportReason,
InvalidProviderOutputReason,
UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag"))
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
module: Schema.String,
method: Schema.String,
reason: LLMErrorReason,
}) {
override readonly cause = this.reason
override get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
return `${this.module}.${this.method}: ${this.reason.message}`
}
}
const members = [
BadRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
QuotaExceeded,
ContentPolicy,
ContextOverflow,
ServerError,
APIError,
ConnectionError,
TimeoutError,
MalformedResponse,
NoRoute,
] as const
export const LLMErrorSchema = Schema.Union(members)
/**
* Every failure of one LLM request. `LLMEvent` streams carry output only;
* all failures HTTP rejections, in-stream provider error events, transport
* failures, and protocol-contract violations exit through this union on
* the stream's error channel.
*/
export type LLMError = typeof LLMErrorSchema.Type
export const isLLMError = (value: unknown): value is LLMError =>
members.some((member) => value instanceof member)
/**
* Failure type for tool execute handlers. Handlers must map their internal
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
+20 -1
View File
@@ -2,6 +2,7 @@ import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
/**
* Token usage reported by an LLM provider.
@@ -196,6 +197,14 @@ export const Finish = Schema.Struct({
}).annotate({ identifier: "LLM.Event.Finish" })
export type Finish = Schema.Schema.Type<typeof Finish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
message: Schema.String,
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ProviderError" })
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
StepStart,
TextStart,
@@ -212,6 +221,7 @@ const llmEventTagged = Schema.Union([
ToolError,
StepFinish,
Finish,
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
@@ -261,6 +271,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
...input,
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
}),
providerError: ProviderErrorEvent.make,
is: {
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
@@ -277,6 +288,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolError: llmEventTagged.guards["tool-error"],
stepFinish: llmEventTagged.guards["step-finish"],
finish: llmEventTagged.guards.finish,
providerError: llmEventTagged.guards["provider-error"],
},
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
@@ -362,6 +374,13 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
finishReason: event.reason,
}
}
if (LLMEvent.is.providerError(event)) {
return {
...state,
events,
finishReason: state.finishReason ?? "error",
}
}
return {
...state,
events,
@@ -570,7 +589,7 @@ export namespace LLMResponse {
/** Purely fold one provider-neutral event into the attempt assembly state. */
export const reduce = reduceResponseState
/** Return a completed response only after a terminal finish event. */
/** Return a completed response only after a terminal finish or provider error. */
export const complete = (state: State): LLMResponse | undefined =>
state.finishReason === undefined
? undefined
+35 -31
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, isLLMError, type LLMError } from "../src"
import { LLM, LLMError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http"
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
)
const expectLLMError = (error: unknown) => {
expect(isLLMError(error)).toBe(true)
if (!isLLMError(error)) throw new Error("expected LLMError")
expect(error).toBeInstanceOf(LLMError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
return error
}
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("classifies context overflow responses", () =>
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
responsesLayer([
@@ -91,7 +91,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
@@ -101,7 +102,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
@@ -112,22 +114,24 @@ describe("RequestExecutor", () => {
expectLLMError(error)
expect(error).toMatchObject({
_tag: "LLM.RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
reason: {
_tag: "RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
},
},
},
@@ -165,8 +169,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
retryAfterMs: 0,
limit: { requests: "500", tokens: "30000" },
remaining: { requests: "499", tokens: "29900" },
@@ -198,7 +202,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
limit: { requests: "100", "input-tokens": "10000" },
@@ -241,12 +245,12 @@ describe("RequestExecutor", () => {
)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("marks 504 and 529 status responses as server errors", () =>
it.effect("marks 504 and 529 status responses as provider-internal", () =>
Effect.gen(function* () {
const failWith = (status: number) =>
Effect.gen(function* () {
@@ -254,7 +258,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe(
Effect.provide(
responsesLayer([
@@ -277,7 +281,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384)
}).pipe(
@@ -356,7 +360,7 @@ describe("RequestExecutor", () => {
)
expectLLMError(error)
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
+2 -2
View File
@@ -149,8 +149,8 @@ describe("request option precedence", () => {
}),
).pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "LLM.BadRequest",
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { weatherToolName } from "../recorded-scenarios"
@@ -22,9 +22,6 @@ const malformedToolOrderRequest = LLM.request({
Message.user("Use that result to answer briefly."),
],
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
// The cassette predates the `cache: "auto"` default; pin the policy off so
// the replayed request matches the recorded wire shape.
cache: "none",
})
const recorded = recordedTests({
@@ -36,17 +33,13 @@ const recorded = recordedTests({
})
describe("Anthropic Messages sad-path recorded", () => {
recorded.effect.with(
"rejects malformed assistant tool order",
// The cassette predates a test rename; keep replaying the existing recording.
{ id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@@ -484,25 +484,23 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("fails the stream for mid-stream provider errors", () =>
it.effect("emits provider-error events for mid-stream provider errors", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
),
Effect.flip,
)
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
}),
)
it.effect("classifies prompt-too-long provider errors", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -511,35 +509,35 @@ describe("Anthropic Messages route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "invalid_request_error: prompt is too long: 210000 tokens",
})
expect(response.events).toEqual([
{
type: "provider-error",
message: "invalid_request_error: prompt is too long: 210000 tokens",
classification: "context-overflow",
},
])
}),
)
it.effect("falls back to error type when no message is present", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
}),
)
it.effect("falls back to a stable default when error payload is absent", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
}),
)
@@ -555,8 +553,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
@@ -355,31 +355,33 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("fails the stream for throttlingException", () =>
it.effect("emits provider-error for throttlingException", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["throttlingException", { message: "Slow down" }],
)
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
type: "provider-error",
message: "Slow down",
})
}),
)
it.effect("classifies input-too-long validation exceptions", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
type: "provider-error",
message: "Input is too long for requested model",
classification: "context-overflow",
})
}),
)
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared"
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
Effect.flip,
)
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -614,11 +614,8 @@ describe("OpenAI Chat route", () => {
const input = LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
})
const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
const events = Array.from(
yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
@@ -629,7 +626,6 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
}),
)
@@ -666,8 +662,8 @@ describe("OpenAI Chat route", () => {
Effect.flip,
)
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -1368,41 +1368,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("fails the stream for mid-stream provider errors", () =>
it.effect("emits provider-error events for mid-stream provider errors", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
Effect.flip,
)
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
}),
)
it.effect("falls back to error code when no message is present", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
}),
)
it.effect("falls back to error code when message is empty", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
}),
)
@@ -1412,7 +1408,7 @@ describe("OpenAI Responses route", () => {
// "OpenAI Responses response failed" string, hiding the real cause.
it.effect("surfaces response.failed details from response.error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1424,16 +1420,15 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1442,10 +1437,9 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
}),
)
@@ -1456,7 +1450,7 @@ describe("OpenAI Responses route", () => {
// when they bubble up an HTTP error as an SSE `error` event. Honour
// both shapes so the user still sees the underlying cause instead
// of the catch-all string.
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1465,19 +1459,21 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
}),
)
it.effect("surfaces error event details nested under error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1492,19 +1488,21 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
}),
)
it.effect("accepts nullable fields in spec-compliant error events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1516,43 +1514,39 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
}),
)
it.effect("falls back to a stable default when error is null", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
}),
)
@@ -1568,8 +1562,8 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
+3 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { isLLMError } from "../src/schema"
import { LLMError } from "../src/schema"
import { ToolStream } from "../src/protocols/utils/tool-stream"
import { it } from "./lib/effect"
@@ -40,9 +40,8 @@ describe("ToolStream", () => {
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(isLLMError(error)).toBe(true)
if (ToolStream.isError(error))
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
expect(error).toBeInstanceOf(LLMError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
}),
)

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