Compare commits

..
Author SHA1 Message Date
Kit LangtonandOpenCode Agent 43a0166303 fix(tui): call work panel activity 2026-07-16 15:14:04 +00:00
46 changed files with 225 additions and 892 deletions
+6 -24
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { RequestTransform, Transport, TransportRuntime } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -155,7 +155,6 @@ export interface Interface {
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
readonly withRequestTransform: (transform: RequestTransform) => Interface
}
export interface StreamMethod {
@@ -412,22 +411,6 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
}),
)
const makeClient = (runtime: TransportRuntime): Interface => {
const stream = streamRequestWith(runtime)
return {
prepare: prepareWith as Interface["prepare"],
stream,
generate: generateWith(stream),
withRequestTransform: (transform) =>
makeClient({
...runtime,
transformRequest: runtime.transformRequest
? (request) => runtime.transformRequest!(request).pipe(Effect.flatMap(transform))
: transform,
}),
}
}
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
@@ -466,12 +449,11 @@ export const streamRequest = (request: LLMRequest) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of(
makeClient({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}),
)
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
+1 -1
View File
@@ -22,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { RequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
+13 -26
View File
@@ -5,7 +5,7 @@ import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { InvalidRequestReason, LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -130,36 +130,23 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
(runtime.transformRequest
? HttpClientRequest.toWeb(prepared.request).pipe(
Effect.mapError(
(error) =>
new LLMError({
module: "HttpTransport",
method: "frames",
reason: new InvalidRequestReason({ message: error.message }),
}),
),
Effect.flatMap(runtime.transformRequest),
Effect.map((request) => HttpClientRequest.fromWeb(request.clone() as Request)),
Effect.flatMap(runtime.http.execute),
)
: runtime.http.execute(prepared.request)
).pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
runtime.http
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
),
})
-3
View File
@@ -8,11 +8,8 @@ import type { LLMError, LLMRequest } from "../../schema"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface
readonly transformRequest?: RequestTransform
}
export type RequestTransform = (request: Request) => Effect.Effect<Request, LLMError>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -690,33 +690,4 @@ describe("OpenAI Chat route", () => {
expect(events.map((event) => event.type)).toEqual(["step-start"])
}),
)
it.effect("transforms the serialized HTTP request before dispatch", () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const seen: string[] = []
yield* llm
.withRequestTransform((request) =>
Effect.sync(() => {
seen.push(request.url)
const headers = new Headers(request.headers)
headers.set("x-hook", "enabled")
return new Request(request, { headers })
}),
)
.stream(request)
.pipe(Stream.runDrain)
expect(seen).toEqual(["https://api.openai.test/v1/chat/completions"])
}).pipe(
Effect.provide(
dynamicResponse((input) => {
expect(input.request.headers["x-hook"]).toBe("enabled")
return Effect.succeed(
input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }),
)
}),
),
),
)
})
+101 -71
View File
@@ -4,9 +4,10 @@ This is the checkable support matrix for CodeMode's confined JavaScript interpre
standard-library surface that programs can use today, plus concrete gaps that may be implemented later.
- `[x]` means the feature is implemented at the scope described here.
- `[ ]` means a concrete compatibility gap remains.
- Checked items do not promise complete ECMAScript edge-case parity; known differences are stated explicitly.
- Intentional boundaries are not listed as compatibility work.
- `[ ]` means the feature is unavailable, incomplete, or intentionally divergent as described.
- A checked item does not promise complete ECMAScript edge-case parity. Known differences are listed next to the
supported surface or under [Known semantic gaps](#known-semantic-gaps).
- [Intentional exclusions](#intentional-exclusions) are boundaries, not backlog.
When behavior changes, update this file and the tests in the same change. The implementation and tests remain the
ultimate source of truth.
@@ -18,45 +19,42 @@ ultimate source of truth.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments remain subject to their schema and the outbound-handling gap listed below.
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams.
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
`undefined` are no-ops, while arrays are rejected.
- [x] Object literals with shorthand, computed string/number keys, and object spread.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
- [ ] BigInt literals and values.
- [ ] Symbols.
- [ ] Tagged template literals.
- [ ] Getters and setters in object literals.
## Bindings and destructuring
- [x] `const`, `let`, and accepted `var` declarations.
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
- [x] Nested patterns, defaults, elisions, and rest elements.
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
fields.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Assignment to identifiers, object fields, array indexes, and writable URL fields.
- [x] Function declarations are hoisted within their interpreted scope.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [ ] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone.
- [ ] Hoist function declarations accepted directly in switch cases.
- [ ] JavaScript-correct `var` function scope, hoisting, and redeclaration. Accepted `var` currently behaves like a
lexical declaration; prefer `let` or `const`.
- [ ] Complete `let`/`const` temporal-dead-zone and declaration-hoisting semantics.
- [ ] Computed object destructuring keys such as `const { [field]: value } = record`.
- [ ] Object destructuring from arrays, such as `const { length } = values`.
- [ ] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values.
- [ ] Dynamic property deletion with `delete object[key]`.
## Statements and control flow
@@ -71,6 +69,7 @@ ultimate source of truth.
- [x] `throw` with arbitrary values.
- [ ] Labeled statements, labeled `break`, and labeled `continue`.
- [ ] `for await...of` and async iteration.
- [ ] `with` and `debugger` statements.
## Functions and callbacks
@@ -91,15 +90,15 @@ ultimate source of truth.
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
coercion.
- [x] Async string replacement callbacks; replacements are evaluated sequentially.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] `this`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`,
and `bind`.
- [ ] Classes and private fields.
- [ ] Generator functions and `yield`.
- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with
`Promise.all`, but a promise is not a meaningful predicate or sort result.
## Expressions and operators
@@ -109,16 +108,16 @@ ultimate source of truth.
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
- [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting.
- [x] Unary `+`, unary `-`, `void`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length.
- [ ] Unary `void` and `delete`.
- [ ] Arbitrary constructors.
## Promises and tools
@@ -153,13 +152,8 @@ ultimate source of truth.
promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including
`.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data
boundary.
- [ ] Thenable assimilation; objects with a callable `then` field remain plain data.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run;
retain null normalization for program results and JSON serialization.
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
- [ ] Async iterables, host streams, and stream consumption.
## Objects and properties
@@ -170,17 +164,19 @@ ultimate source of truth.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [x] `Object.is` for supported data values.
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
- [ ] `Object.groupBy`.
- [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs.
- [ ] A final policy for legal data keys named `__proto__`, `constructor`, or `prototype` (tool path segments
already allow them; see known semantic gaps).
## Arrays
- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments and `Array(n)` creates a sparse
array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like
JavaScript, and host results normalize holes to `null`.
- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments, `Array(n)` creates a
sparse array of that length (invalid lengths throw a `RangeError`). Holes behave like JS in iteration,
spread, join, and JSON; `sort` densifies holes into trailing `undefined`, and results returned to the
host normalize holes to `null`.
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
`(value, index)` arguments.
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
@@ -194,10 +190,8 @@ ultimate source of truth.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than
aliasing index `1`.
- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own
`undefined` elements.
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
## Strings
@@ -210,8 +204,8 @@ ultimate source of truth.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
reject instead of coercing.
- [ ] Locale/options-aware `localeCompare` and locale formatting APIs.
- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers.
- [ ] Native no-argument parity for `match()` and `search()`.
## Numbers and Math
@@ -227,21 +221,20 @@ ultimate source of truth.
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
`Number(...)` directly.
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
during property access.
- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion.
- [ ] Reliable feature detection for unknown static members.
- [ ] `Math.sumPrecise`.
- [ ] Global coercing `isFinite` and `isNaN`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [x] `JSON.parse` and `JSON.stringify`.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
- [ ] `JSON.parse` reviver callbacks.
- [ ] `JSON.stringify` function/array replacers.
- [ ] Other console methods, timers, counters, groups, and host console access.
## Date
@@ -257,22 +250,22 @@ ultimate source of truth.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [ ] Date setters.
- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of
being coerced.
- [ ] Native Date loose-equality and default primitive-coercion semantics.
- [ ] `toUTCString`, locale methods, and other Date formatting methods.
- [ ] Exact native constructor coercion, local-time, and loose-equality semantics.
- [ ] Native `RangeError` branding for invalid `toISOString()` calls.
- [ ] Temporal and Intl date/time APIs.
## Regular expressions
- [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Captures, named groups, match indexes, and stateful global matching.
- [x] Integration with supported String methods, including async function replacers.
- [ ] Writable `lastIndex`.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] Exposed metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout.
## Map and Set
@@ -283,8 +276,9 @@ ultimate source of truth.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [ ] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
`isSupersetOf`, and `isDisjointFrom`.
- [ ] Set composition methods such as `union`, `intersection`, `difference`, and relation predicates.
- [ ] WeakMap and WeakSet.
- [ ] Native iterator objects and custom iterators.
## URL and URI helpers
@@ -307,12 +301,48 @@ ultimate source of truth.
an all-rejected `Promise.any`.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Catchable interpreter failures and awaited tool failures.
- [x] Source locations on unsupported-syntax diagnostics when available.
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
- [ ] Distinct public categories for user throws, tool refusal, tool internal failure, invalid returned data, compile
failures, and genuine interpreter defects.
- [ ] Preservation of detailed recoverable failure categories inside `catch` and `Promise.allSettled`.
## Known semantic gaps
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`.
- [x] Canonicalize dotted tool names into namespace paths so every advertised dotted path is executable, one
canonical path can be both a callable tool and a namespace, and the last definition supplied for a canonical
path wins.
- [x] Allow blocked member names (`constructor`, `prototype`, `__proto__`) as tool path segments: segments are Map
keys and inert strings, never plain-object property accesses, so every advertised path is executable. Blocked
member access on data values stays rejected. Tool names with empty segments are rejected at construction.
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
`null` in render-only or OpenAPI tool calls.
- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly.
- [ ] Complete lexical declaration and destructuring semantics listed above.
- [x] Make callback acceptance consistent across built-ins: collections, sort, string replacers, `Array.from`
mappers, and promise reactions share one acceptance rule.
- [x] Reject every unsupported callable callback argument explicitly rather than silently ignoring it
(`JSON.stringify` replacers and `JSON.parse` revivers fail loudly). The iteration-method `thisArg` is
accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow semantics.
- [ ] Make async callback behavior consistent across built-ins; only string replacers settle async callback results
today.
- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections.
- [ ] Make tool search tokenization Unicode-aware.
- [ ] Design explicit tagged representations and size limits before adding binary values or streams.
## Intentional exclusions
These constraints preserve CodeMode's confinement and host-neutral scope. They are not TODO items.
- Ambient filesystem, process, environment, credential, network, or application access.
- `fetch`, timers, crypto, or other host globals unless a future host explicitly supplies a bounded capability.
- Static imports, dynamic imports, modules, npm packages, and module loading.
- `eval`, `Function(...)`, arbitrary host execution, and prototype mutation.
- Generic permission prompts, authorization policy, persistence, replay, or exactly-once side effects.
- Arbitrary method dispatch outside the documented allowlists.
- Automatic parsing of text tool results as JSON.
- Full browser, Node.js, Bun, or ECMAScript runtime compatibility.
@@ -1279,7 +1279,6 @@ export class Interpreter<R> {
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const operator = getString(node, "operator")
const argument = getNode(node, "argument")
if (operator === "delete") return this.evaluateDeleteExpression(argument)
// Undeclared names short-circuit, but declared TDZ bindings must still throw.
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) {
return Effect.succeed("undefined")
@@ -1287,7 +1286,6 @@ export class Interpreter<R> {
return Effect.map(this.evaluateExpression(argument), (value) => {
if (operator === "typeof") return typeofValue(value)
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
}
@@ -1731,7 +1729,6 @@ export class Interpreter<R> {
private getMemberReference(
node: AstNode,
operation: "read" | "delete" = "read",
): Effect.Effect<
| MemberReference
| ToolReference
@@ -1878,7 +1875,6 @@ export class Interpreter<R> {
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
if (
key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
@@ -1927,29 +1923,6 @@ export class Interpreter<R> {
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
}
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument)
}
return Effect.map(this.getMemberReference(target, "delete"), (reference) => {
if (reference === OptionalShortCircuit) return true
if (
reference instanceof ComputedValue ||
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference ||
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
}
return Reflect.deleteProperty(reference.target, reference.key)
})
}
// Resolve side-effecting object and key expressions exactly once.
private modifyMember(
node: AstNode,
-6
View File
@@ -1,5 +1,4 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -45,11 +44,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values in CodeMode.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
-78
View File
@@ -99,84 +99,6 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
})
})
describe("unary void", () => {
test("evaluates its operand and returns undefined", async () => {
expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([
1,
true,
])
})
test("discards opaque values", async () => {
expect(await value(`return void tools === undefined`)).toBe(true)
})
})
describe("property deletion", () => {
test("deletes plain object fields and reports missing fields as successful", async () => {
expect(
await value(`
const object = { keep: 1, remove: 2 }
return [delete object.remove, delete object.missing, object]
`),
).toEqual([true, true, { keep: 1 }])
})
test("evaluates computed object and key expressions once", async () => {
expect(
await value(`
const object = { remove: true }
let objectReads = 0
let keyReads = 0
function getObject() { objectReads++; return object }
function getKey() { keyReads++; return "remove" }
const removed = delete getObject()[getKey()]
return [removed, objectReads, keyReads, Object.hasOwn(object, "remove")]
`),
).toEqual([true, 1, 1, false])
})
test("deleting an array index creates a hole without changing its length", async () => {
expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([
true,
3,
false,
[1, null, 3],
])
})
test("array length is not configurable", async () => {
expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2])
})
test("does not broaden unsupported array property assignment", async () => {
expect(
await value(`
const values = []
let rightHandSideRuns = 0
function next() { rightHandSideRuns++; return 1 }
try { values.field = next() } catch {}
return rightHandSideRuns
`),
).toBe(0)
})
test("optional deletion short-circuits without evaluating the key", async () => {
expect(
await value(`let keyReads = 0; const object = null; return [delete object?.[keyReads++], keyReads]`),
).toEqual([true, 0])
})
test("rejects deletion from opaque runtime references", async () => {
expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
})
test("keeps blocked property names unavailable", async () => {
expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
})
})
describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
test("guards run instead of the program crashing on a transient NaN", async () => {
expect(await value(`return parseInt("abc") || 0`)).toBe(0)
-18
View File
@@ -593,24 +593,6 @@ describe("Set", () => {
})
describe("stdlib integration", () => {
test("Object.is uses SameValue semantics", async () => {
expect(
await value(`
const object = {}
return [
Object.is(NaN, NaN),
Object.is(0, -0),
Object.is(object, object),
Object.is({}, {}),
]
`),
).toEqual([true, false, true, false])
})
test("Object.is rejects opaque runtime references", async () => {
expect((await error(`return Object.is(Math.max, Math.max)`)).kind).toBe("InvalidDataValue")
})
test("Object values and entries accept arrays", async () => {
expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([
["a", "b"],
-112
View File
@@ -1,112 +0,0 @@
const token = "dummy-wellknown-token"
const port = Number(process.env.PORT ?? 8787)
const config = {
$schema: "https://opencode.ai/config.json",
share: "manual",
model: "example-primary/example-chat",
enabled_providers: ["example-primary", "example-secondary"],
disabled_providers: ["opencode", "anthropic", "openai", "google", "xai", "amazon-bedrock", "azure"],
provider: {
"example-primary": {
name: "Example Primary",
npm: "@ai-sdk/openai-compatible",
whitelist: ["example-chat", "example-code"],
options: {
baseURL: "https://models.example.com/v1",
apiKey: "{env:TOKEN}",
},
models: {
"example-chat": {
name: "Example Chat",
reasoning: true,
tool_call: true,
attachment: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 128000,
output: 16000,
},
},
"example-code": {
name: "Example Code",
reasoning: true,
tool_call: true,
limit: {
context: 200000,
output: 32000,
},
},
},
},
"example-secondary": {
name: "Example Secondary",
npm: "@ai-sdk/openai-compatible",
whitelist: ["example-fast"],
options: {
baseURL: "https://inference.example.org/v1",
apiKey: "{env:TOKEN}",
},
models: {
"example-fast": {
name: "Example Fast",
tool_call: true,
limit: {
context: 64000,
output: 8000,
},
},
},
},
},
mcp: {
"example-tools": {
type: "remote",
url: "https://tools.example.net/mcp",
enabled: false,
},
},
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
},
},
}
const server = Bun.serve({
hostname: "127.0.0.1",
port,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/opencode") {
return Response.json({
auth: {
command: ["bun", "-e", `await Bun.sleep(5000); process.stdout.write(${JSON.stringify(token)})`],
env: "TOKEN",
},
remote_config: {
url: `${url.origin}/config/opencode.json`,
headers: { authorization: "Bearer {env:TOKEN}" },
},
})
}
if (url.pathname === "/config/opencode.json") {
if (request.headers.get("authorization") !== `Bearer ${token}`) {
return new Response("Unauthorized", { status: 401 })
}
return Response.json(config)
}
return new Response("Not found", { status: 404 })
},
})
console.log(`Well-known fixture listening at ${server.url.origin}`)
console.log(`Test with: bun run dev auth connect ${server.url.origin}`)
+8 -25
View File
@@ -1,6 +1,5 @@
export * as AISDK from "./aisdk"
import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "./effect/app-node"
import type {
JSONSchema7,
@@ -30,7 +29,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute, type TransportRuntime } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@@ -40,7 +39,6 @@ type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
const requestTransform = new AsyncLocalStorage<TransportRuntime["transformRequest"]>()
export interface SDKEvent {
readonly model: ModelV2.Info
@@ -151,20 +149,10 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
}
const request = new Request(input as Request, opts)
const transform = requestTransform.getStore()
const transformed = transform ? await Effect.runPromise(transform(request)) : request
const upstream = typeof customFetch === "function" ? customFetch : fetch
const res = transform
? await upstream(transformed.url, {
...opts,
method: transformed.method,
headers: transformed.headers,
body: transformed === request || transformed.body === null ? opts.body : await transformed.clone().text(),
signal: transformed.signal,
timeout: false,
})
: await upstream(input, { ...opts, timeout: false })
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
})
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
@@ -353,8 +341,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared, _request, runtime) =>
streamLanguage(language, prepared as LanguageModelV3CallOptions, runtime.transformRequest),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
}
@@ -545,17 +532,13 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
function streamLanguage(
language: LanguageModelV3,
options: LanguageModelV3CallOptions,
transform: TransportRuntime["transformRequest"],
) {
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
try: () => requestTransform.run(transform, () => language.doStream(options)),
try: () => language.doStream(options),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
+1 -5
View File
@@ -16,10 +16,6 @@ export interface Domains {
type Callback<Event> = (event: Event) => Effect.Effect<void>
export interface Interface {
readonly has: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
) => boolean
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
@@ -64,7 +60,7 @@ const layer = Layer.effect(
return event
})
return Service.of({ has: (domain, name) => callbacks.has(key(domain, name)), register, trigger })
return Service.of({ register, trigger })
}),
)
+7 -39
View File
@@ -37,7 +37,6 @@ import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "./fs-util"
import { Image } from "./image"
import { Mime } from "./mime"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill"
@@ -532,13 +531,9 @@ const layer = Layer.effect(
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents },
image,
).pipe(Effect.provideService(FSUtil.Service, fs))
const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe(
Effect.provideService(FSUtil.Service, fs),
)
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
type: "user",
@@ -864,13 +859,10 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
}
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
) {
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
@@ -880,7 +872,6 @@ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
image: Effect.Effect<Image.Interface>,
) {
const resolved = input.uri.startsWith("data:")
? {
@@ -909,15 +900,9 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(
input,
Base64.make(Buffer.from(content).toString("base64")),
mime,
image,
)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
data: Base64.make(Buffer.from(content).toString("base64")),
mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
@@ -925,23 +910,6 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
})
})
const normalizeImageAttachment = Effect.fn("V2Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: Base64,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data, mime }
const service = yield* image
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* service.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
+7 -10
View File
@@ -1,18 +1,15 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMEvent, Message, type Model } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
@@ -62,10 +59,11 @@ type Settings = {
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: LLMClientShape
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
@@ -242,7 +240,7 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = []
let failure: SessionError.Error | undefined
yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, plan.session.id)
yield* dependencies.llm
.stream(
LLM.request({
model: plan.model,
@@ -374,13 +372,12 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const hooks = yield* PluginHooks.Service
return make({ events, llm, models, config: settings(yield* config.entries()), hooks })
return make({ events, llm, models, config: settings(yield* config.entries()) })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, PluginHooks.node],
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
})
-13
View File
@@ -1,13 +0,0 @@
export * as SessionRequestHook from "./request-hook"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Effect } from "effect"
import { PluginHooks } from "../plugin/hooks"
import { SessionSchema } from "./schema"
export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, sessionID: SessionSchema.ID) =>
hooks.has("session", "request")
? llm.withRequestTransform((request) =>
hooks.trigger("session", "request", { sessionID, request }).pipe(Effect.map((event) => event.request)),
)
: llm
+1 -5
View File
@@ -8,7 +8,6 @@ import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { PluginHooks } from "../../plugin/hooks"
import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state"
@@ -16,7 +15,6 @@ import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
import { SessionRequestHook } from "../request-hook"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
@@ -67,7 +65,6 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const hooks = yield* PluginHooks.Service
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
@@ -160,7 +157,7 @@ const layer = Layer.effect(
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))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = SessionRequestHook.client(llm, hooks, session.id).stream(prepared.request).pipe(
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -509,6 +506,5 @@ export const node = makeLocationNode({
SessionTitle.node,
Snapshot.node,
Database.node,
PluginHooks.node,
],
})
+7 -10
View File
@@ -1,18 +1,15 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
@@ -20,10 +17,11 @@ const MAX_LENGTH = 100
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: LLMClientShape
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -53,7 +51,7 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return
const chunks: string[] = []
let failed = false
const streamed = yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, session.id)
const streamed = yield* dependencies.llm
.stream(
LLM.request({
model: resolved.model,
@@ -95,8 +93,7 @@ export const layer = Layer.effect(
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const title = make({ events, llm, agents, models, hooks })
const title = make({ events, llm, agents, models })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
@@ -106,5 +103,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, PluginHooks.node],
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
})
+11 -8
View File
@@ -6,6 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@@ -34,6 +35,7 @@ export const Plugin = {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
@@ -48,12 +50,6 @@ export const Plugin = {
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
structured: Schema.toEncoded(Output),
// Image base64 reaches the model through content items (normalized generically
// at tool settlement); persisting a second copy in structured would store the
// original unresized bytes in the message row.
toStructuredOutput: ({ output }) =>
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
@@ -121,14 +117,21 @@ export const Plugin = {
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if ("encoding" in content && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image
.normalize(resource, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
}
if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
+8 -45
View File
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
@@ -58,40 +57,6 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
const image = yield* Image.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
// RFC 2397 permits parameters between the mime and ";base64".
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
.pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
if (count === 0) return []
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
}
return [
...normalized.filter((item) => typeof item !== "string"),
...note("decode", "could not be decoded."),
...note("size", "could not be resized below the image size limit."),
]
})
type Registration = {
readonly tool: AnyTool
readonly name: string
@@ -119,11 +84,10 @@ const registryLayer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
progress: (update) =>
input.progress?.({
structured: update.structured,
content: (update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
@@ -133,8 +97,7 @@ const registryLayer = Layer.effect(
name: part.name,
},
),
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
}) ?? Effect.void,
},
).pipe(
Effect.map((output) => ({ output })),
@@ -152,7 +115,7 @@ const registryLayer = Layer.effect(
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
@@ -269,11 +232,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
deps: [ToolOutputStore.node, ToolHooks.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
deps: [ToolOutputStore.node, ToolHooks.node],
})
+1 -59
View File
@@ -5,7 +5,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, Message } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
@@ -19,64 +19,6 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
limit: { context: 100, output: 20 },
})
it.effect("applies request transforms to AI SDK fetch calls", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let received: Request | undefined
const input = model("test-sdk")
Object.defineProperty(input, "settings", {
value: {
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
received = new Request(input as Request, init)
return new Response()
},
},
})
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () => ({
doStream: async () => {
await event.options.fetch("https://provider.test/generate", { method: "POST", body: "{}" })
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({
type: "finish",
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
})
controller.close()
},
}),
}
},
}),
}
})
const resolved = yield* aisdk.model(input)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const body = yield* resolved.route.body.from(request)
const prepared = yield* resolved.route.prepareTransport(body, request)
yield* resolved.route
.streamPrepared(prepared, request, {
http: { execute: () => Effect.die("unused") },
transformRequest: (request) =>
Effect.sync(() => {
const headers = new Headers(request.headers)
headers.set("x-hook", "enabled")
return new Request(request, { headers })
}),
})
.pipe(Stream.runDrain)
expect(received?.url).toBe("https://provider.test/generate")
expect(received?.headers.get("x-hook")).toBe("enabled")
}),
)
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
-7
View File
@@ -1,7 +0,0 @@
import { Image } from "@opencode-ai/core/image"
import { Effect, Layer } from "effect"
/** Passthrough resizer for tests that build ToolRegistry.node without a Location. */
export const imagePassthrough = Layer.mock(Image.Service, {
normalize: (_resource, content) => Effect.succeed(content),
})
-3
View File
@@ -29,9 +29,7 @@ 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 { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -252,7 +250,6 @@ const it = testEffect(
[PermissionV2.node, permissions],
[EventV2.node, events],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
-20
View File
@@ -42,24 +42,4 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("allows session request hooks to replace the raw request", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
expect(hooks.has("session", "request")).toBe(false)
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
event.request = new Request(event.request, { headers: { "x-hook": "enabled" } })
}),
)
expect(hooks.has("session", "request")).toBe(true)
const event = {
sessionID: Session.ID.make("ses_request_hook"),
request: new Request("https://example.com"),
}
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
expect(event.request.headers.get("x-hook")).toBe("enabled")
}),
)
})
+2 -5
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -41,16 +40,14 @@ const projects = Layer.succeed(
}),
)
let requests: LLMRequest[] = []
const clientShape: LLMClientShape = {
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
},
generate: () => Effect.die("unused"),
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const locations = Layer.effect(
@@ -1,6 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -32,16 +31,14 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const clientShape: LLMClientShape = {
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
},
generate: () => Effect.die("unused"),
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
+2 -17
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
@@ -24,10 +24,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
@@ -52,22 +49,10 @@ const execution = Layer.succeed(
awaitIdle: () => Effect.void,
}),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
imagePassthrough as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
],
[[SessionExecution.node, execution]],
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
@@ -3,7 +3,6 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
@@ -29,28 +28,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const imageStore = Layer.mock(Image.Service, {
normalize: (resource, content) => {
if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
if (resource === "too-large.png")
return Effect.fail(
new Image.SizeError({
resource,
width: 9_000,
height: 9_000,
bytes: content.content.length,
maxWidth: 2_000,
maxHeight: 2_000,
maxBytes: 5,
}),
)
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[ToolOutputStore.node, outputStore],
[Image.node, imageStore],
])
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
@@ -277,75 +255,6 @@ describe("ToolRegistry", () => {
}),
)
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "snapshot" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
])
}),
)
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
...call("progressive"),
progress: (update) =>
Effect.sync(() => {
updates.push(update)
}),
})
expect(updates).toEqual([
{
structured: { stage: "capture" },
content: [
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
},
])
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+5 -4
View File
@@ -89,7 +89,9 @@ let toolExecutionsStarted: Deferred.Deferred<void> | undefined
let toolExecutionsReady = 5
let activeToolExecutions = 0
let maxActiveToolExecutions = 0
const clientValue: LLMClientShape = LLMClient.Service.of({
const client = Layer.succeed(
LLMClient.Service,
LLMClient.Service.of({
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
@@ -111,9 +113,8 @@ const clientValue: LLMClientShape = LLMClient.Service.of({
)
}) as unknown as LLMClientShape["stream"],
generate: () => Effect.die("unused"),
withRequestTransform: () => clientValue,
})
const client = Layer.succeed(LLMClient.Service, clientValue)
}),
)
const reply = {
stop: () => [
LLMEvent.stepStart({ index: 0 }),
+2 -5
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -30,16 +29,14 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const clientShape: LLMClientShape = {
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
},
generate: () => Effect.die("unused"),
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
})
-3
View File
@@ -8,9 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
@@ -86,7 +84,6 @@ const it = testEffect(
[PermissionV2.node, permission],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
+18 -39
View File
@@ -291,9 +291,6 @@ describe("ReadTool", () => {
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
@@ -367,7 +364,7 @@ describe("ReadTool", () => {
}),
)
it.effect("drops undecodable image data at settlement", () =>
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = {
uri: "file:///truncated.png",
@@ -384,17 +381,11 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
],
})
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
}),
)
it.effect("drops oversized images at settlement when resizing is disabled", () =>
it.effect("rejects oversized images when resizing is disabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
@@ -418,20 +409,14 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
}),
)
@@ -475,7 +460,7 @@ describe("ReadTool", () => {
}),
)
it.effect("drops images that cannot fit max base64 bytes after resize attempts", () =>
it.effect("enforces max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
@@ -496,20 +481,14 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("/1 bytes")
}),
)
-3
View File
@@ -12,9 +12,7 @@ import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { Image } from "@opencode-ai/core/image"
import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
@@ -92,7 +90,6 @@ describe("SkillTool", () => {
[PermissionV2.node, permission],
[SkillV2.node, skills],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
)
-3
View File
@@ -11,9 +11,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
@@ -52,7 +50,6 @@ const toolLayer = (replacements: LayerNode.Replacements = []) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
...replacements,
])
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
@@ -10,9 +10,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
@@ -140,7 +138,6 @@ const it = testEffect(
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
),
)
-13
View File
@@ -92,19 +92,6 @@ yield *
)
```
The serialized provider request is also mutable before it is sent:
```ts
yield *
ctx.session.hook("request", (event) =>
Effect.sync(() => {
event.request = new Request(event.request, {
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
})
}),
)
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
-6
View File
@@ -15,14 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest {
readonly sessionID: Session.ID
request: Request
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
-10
View File
@@ -94,16 +94,6 @@ await ctx.session.hook("context", (event) => {
})
```
The serialized provider request is also mutable before it is sent:
```ts
await ctx.session.hook("request", (event) => {
event.request = new Request(event.request, {
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
})
})
```
Promise tools use plain object declarations with async executors:
```ts
@@ -15,14 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest {
readonly sessionID: Session.ID
request: Request
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt"> & {
+1 -1
View File
@@ -97,7 +97,7 @@ export const Definitions = {
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
session_child_first: keybind("down,<leader>down", "Show or hide activity"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
@@ -93,13 +93,13 @@ export function Composer(props: ComposerProps) {
mode: "composer",
enabled: () => props.open,
commands: [
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
{ bind: "left", title: "Previous tab", group: "Activity", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Activity", run: () => switchTab(1) },
{ bind: "escape", title: "Close activity", group: "Activity", run: close },
{
bind: "<leader>down",
title: "Toggle composer",
group: "Composer",
title: "Hide activity",
group: "Activity",
run: close,
},
],
@@ -57,7 +57,7 @@ export function ShellTab(props: { sessionID: string }) {
{
id: "composer.shell.up",
title: "Previous shell",
group: "Composer",
group: "Activity",
bind: "up",
run() {
const list = entries()
@@ -68,7 +68,7 @@ export function ShellTab(props: { sessionID: string }) {
{
id: "composer.shell.down",
title: "Next shell",
group: "Composer",
group: "Activity",
bind: "down",
run() {
const list = entries()
@@ -79,7 +79,7 @@ export function ShellTab(props: { sessionID: string }) {
{
id: "composer.shell.kill",
title: "Kill shell command",
group: "Composer",
group: "Activity",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
@@ -150,7 +150,7 @@ export function SubagentsTab(props: { sessionID: string }) {
{
id: "composer.subagent.up",
title: "Previous subagent",
group: "Composer",
group: "Activity",
bind: "up",
run() {
const list = entries()
@@ -161,7 +161,7 @@ export function SubagentsTab(props: { sessionID: string }) {
{
id: "composer.subagent.down",
title: "Next subagent",
group: "Composer",
group: "Activity",
bind: "down",
run() {
const list = entries()
@@ -172,7 +172,7 @@ export function SubagentsTab(props: { sessionID: string }) {
{
id: "composer.subagent.select",
title: "Navigate to subagent",
group: "Composer",
group: "Activity",
bind: "return",
run() {
const entry = entries()[store.selected]
@@ -182,7 +182,7 @@ export function SubagentsTab(props: { sessionID: string }) {
{
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Composer",
group: "Activity",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
+7 -12
View File
@@ -740,7 +740,7 @@ export function Session() {
},
},
{
title: "Toggle subagent picker",
title: composer.open || !!session()?.parentID ? "Hide activity" : "Show activity",
name: "session.child.first",
category: "Session",
run: () => {
@@ -2548,8 +2548,10 @@ function WebSearch(props: ToolProps) {
function Subagent(props: ToolProps) {
const { navigate } = useRoute()
const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input))
const metadata = createMemo(() => (props.part.state.status === "streaming" ? {} : props.part.state.structured))
const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId))
const description = createMemo(() => stringValue(input().description))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
@@ -2567,23 +2569,16 @@ function Subagent(props: ToolProps) {
if (id) navigate({ type: "session", sessionID: id })
}}
status={
isBackgroundSubagent(props.metadata, props.part.state.status) ? (
input().background === true || metadata().status === "running" ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
>
{`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
{`${Locale.titlecase(stringValue(input().agent) ?? stringValue(input().subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
</InlineTool>
)
}
export function isBackgroundSubagent(
metadata: Record<string, unknown>,
status: SessionMessageAssistantTool["state"]["status"],
) {
return status === "completed" && metadata.status === "running"
}
export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
@@ -4,7 +4,6 @@ import { testRender, type JSX } from "@opentui/solid"
import {
formatSubagentRetry,
InlineToolRow,
isBackgroundSubagent,
parseApplyPatchFiles,
parseDiagnostics,
parseQuestionAnswers,
@@ -202,13 +201,6 @@ describe("TUI inline tool wrapping", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("labels only detached or async subagents as background", () => {
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
expect(isBackgroundSubagent({ status: "running" }, "error")).toBeFalse()
expect(isBackgroundSubagent({ status: "completed" }, "completed")).toBeFalse()
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
})
+1 -1
View File
@@ -86,7 +86,7 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("opens the subagent picker with down", () => {
test("opens activity with down", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>down" }])