Compare commits

..

15 Commits

Author SHA1 Message Date
James Long 3d8e1ecafc feat(tui): clarify theme mode toggle 2026-07-16 19:02:51 +00:00
James Long 4885c3a5d3 test(tui): add theme screenshot gallery 2026-07-16 18:54:39 +00:00
Dax Raad a7cf21e157 refactor(core): move wellknown refresh to config 2026-07-16 14:32:46 -04:00
Kit Langton e4a16830f1 feat(core): add transient session generation (#37330) 2026-07-16 14:28:54 -04:00
Dax Raad 103f764624 feat(core): add provider policy enforcement 2026-07-16 14:28:18 -04:00
James Long 7a7075d86f feat(tui): refine V2 theme colors (#37346) 2026-07-16 14:01:25 -04:00
Aiden Cline c764732aea feat(codemode): expand destructuring support (#37342) 2026-07-16 12:57:37 -05:00
Dax Raad 72af084dc1 refactor(core): make state transforms synchronous 2026-07-16 13:55:14 -04:00
Dax Raad 65a42fd549 fix(core): debounce state reloads 2026-07-16 13:34:46 -04:00
Aiden Cline ad8e6b1fb6 feat(codemode): support property deletion (#37335) 2026-07-16 12:21:28 -05:00
Kit Langton 041cda905d fix(tui): label only detached subagents as background (#37306) 2026-07-16 12:21:19 -04:00
Aiden Cline 2ad6c42143 feat(core): normalize tool and attachment images at settlement (#37141) 2026-07-16 11:16:19 -05:00
Aiden Cline 60c7f847c1 feat(codemode): support void and Object.is (#37332) 2026-07-16 11:15:29 -05:00
Dax Raad 7ab8a08efa sync 2026-07-16 12:08:12 -04:00
Aiden Cline 7388e69b5c chore(codemode): update interpreter support 2026-07-16 10:44:44 -05:00
164 changed files with 3843 additions and 463 deletions
+73 -103
View File
@@ -4,10 +4,9 @@ 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 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.
- `[ ]` 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.
When behavior changes, update this file and the tests in the same change. The implementation and tests remain the
ultimate source of truth.
@@ -19,42 +18,45 @@ 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] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
- [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] 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 object spread.
- [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] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and values.
- [ ] Symbols.
- [ ] Tagged template literals.
- [ ] Getters and setters in object literals.
- [ ] 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.
## 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, object fields, array indexes, and writable URL fields.
- [x] Function declarations are hoisted within their interpreted scope.
- [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] Parameter defaults observe a temporal dead zone for later parameters.
- [ ] 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`.
- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values.
- [ ] Dynamic property deletion with `delete object[key]`.
- [ ] 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.
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
- [x] Object destructuring from arrays, such as `const { length } = values`.
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
## Statements and control flow
@@ -69,7 +71,6 @@ 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
@@ -90,15 +91,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.
- [x] Async string replacement callbacks; replacements are evaluated sequentially.
- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
coercion.
- [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`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`,
and `bind`.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] 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
@@ -108,16 +109,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 Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
- [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting.
- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Unary `+`, unary `-`, `void`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [ ] Unary `void` and `delete`.
- [ ] Arbitrary constructors.
- [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.
## Promises and tools
@@ -152,8 +153,13 @@ 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 `then` method are plain data, not promises).
- [ ] Async iterables, host streams, and stream consumption.
- [ ] 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.
## Objects and properties
@@ -164,19 +170,17 @@ 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] Blocked access to `__proto__`, `constructor`, and `prototype`.
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
- [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.
- [ ] `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, `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] 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] 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`.
@@ -190,8 +194,10 @@ 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 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.
- [ ] 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.
## Strings
@@ -204,8 +210,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`.
- [ ] Locale/options-aware `localeCompare` and locale formatting APIs.
- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers.
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
reject instead of coercing.
- [ ] Native no-argument parity for `match()` and `search()`.
## Numbers and Math
@@ -221,20 +227,21 @@ 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 `""`.
- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion.
- [ ] Reliable feature detection for unknown static members.
- [ ] `++` 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.
- [ ] `Math.sumPrecise`.
- [ ] Global coercing `isFinite` and `isNaN`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify`.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [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
@@ -250,22 +257,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.
- [ ] `toUTCString`, locale methods, and other Date formatting methods.
- [ ] Exact native constructor coercion, local-time, and loose-equality semantics.
- [ ] `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.
- [ ] 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, named groups, match indexes, and stateful global matching.
- [x] Integration with supported String methods, including async function replacers.
- [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.
- [ ] Writable `lastIndex`.
- [ ] Exposed metadata for the `d` and `v` flags.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout.
## Map and Set
@@ -276,9 +283,8 @@ 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 methods such as `union`, `intersection`, `difference`, and relation predicates.
- [ ] WeakMap and WeakSet.
- [ ] Native iterator objects and custom iterators.
- [ ] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
`isSupersetOf`, and `isDisjointFrom`.
## URL and URI helpers
@@ -301,48 +307,12 @@ 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 interpreter failures and awaited tool failures.
- [x] Source locations on unsupported-syntax diagnostics when available.
- [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] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] 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.
- [ ] 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.
+77 -40
View File
@@ -777,9 +777,9 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
@@ -798,38 +798,35 @@ export class Interpreter<R> {
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
consumed.add(String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
mutable,
property,
)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
break
}
yield* self.declarePattern(element, value[index], mutable, pattern)
yield* self.declarePattern(element, items[index], mutable, pattern)
}
return
}
@@ -858,15 +855,15 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
}
const source = value as SafeObject
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -878,36 +875,29 @@ export class Interpreter<R> {
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.assignPattern(getNode(property, "value"), source[key], property)
consumed.add(String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element)
yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element)
break
}
yield* self.assignPattern(element, value[index], pattern)
yield* self.assignPattern(element, items[index], pattern)
}
return
}
@@ -916,6 +906,26 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
const keyNode = getNode(property, "key")
if (getBoolean(property, "computed")) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
switch (node.type) {
case "Literal": {
@@ -1279,6 +1289,7 @@ 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")
@@ -1286,6 +1297,7 @@ 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")
}
@@ -1729,6 +1741,7 @@ export class Interpreter<R> {
private getMemberReference(
node: AstNode,
operation: "read" | "delete" = "read",
): Effect.Effect<
| MemberReference
| ToolReference
@@ -1875,6 +1888,7 @@ export class Interpreter<R> {
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
if (
key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
@@ -1923,6 +1937,29 @@ 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,4 +1,5 @@
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"
@@ -44,6 +45,11 @@ 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)) {
+145
View File
@@ -99,6 +99,84 @@ 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)
@@ -470,4 +548,71 @@ describe("destructuring assignment", () => {
test("returns the assigned value", async () => {
expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
})
test("supports computed object keys and evaluates them once", async () => {
expect(
await value(`
let calls = 0
const field = () => { calls++; return "name" }
const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" }
return { calls, name, rest }
`),
).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } })
})
test("supports object patterns over arrays", async () => {
expect(
await value(`
const { 0: first, length, slice, ...rest } = ["a", "b", "c"]
return { first, length, sliced: slice(1), rest }
`),
).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } })
})
test("preserves exact computed property names on arrays", async () => {
expect(
await value(`
const { ["01"]: item, ...rest } = [10, 20]
return { missing: item === undefined, rest }
`),
).toEqual({ missing: true, rest: { 0: 10, 1: 20 } })
})
test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => {
expect(
await value(`
const [letter, ...letters] = "A😀B"
const [[mapKey, mapValue]] = new Map([["key", 1]])
const [setFirst, setSecond] = new Set([2, 3])
const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2")
return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue }
`),
).toEqual({
letter: "A",
letters: ["😀", "B"],
mapKey: "key",
mapValue: 1,
setFirst: 2,
setSecond: 3,
queryKey: "q",
queryValue: "test",
})
})
test("supports iterable patterns in assignment and parameters", async () => {
expect(
await value(`
let first
let rest
;[first, ...rest] = new Set([1, 2, 3])
const read = ([[key, value]]) => key + value
return { first, rest, entry: read(new Map([["a", 4]])) }
`),
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
})
})
+18
View File
@@ -593,6 +593,24 @@ 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
@@ -0,0 +1,112 @@
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}`)
+32 -8
View File
@@ -2,8 +2,9 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
@@ -165,6 +166,7 @@ const layer = Layer.effect(
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
@@ -326,12 +328,17 @@ const layer = Layer.effect(
}
})
const reload = Effect.fn("Config.reload")(function* () {
const next = yield* discover()
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
@@ -354,12 +361,29 @@ const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown.refresh().pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
+4
View File
@@ -2,9 +2,13 @@ export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { ConfigPolicy } from "./policy"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+35
View File
@@ -0,0 +1,35 @@
export * as ConfigPolicyPlugin from "./policy"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Config.Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+13
View File
@@ -0,0 +1,13 @@
export * as ConfigPolicy from "./policy"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny"])
export type Effect = typeof Effect.Type
export const Info = Schema.Struct({
action: Schema.Literal("provider.use"),
resource: Schema.String,
effect: Effect,
})
export type Info = typeof Info.Type
+2
View File
@@ -39,6 +39,7 @@ import { InstructionDiscovery } from "./instruction-discovery"
import { InstructionBuiltIns } from "./instructions/builtins"
import { InstructionEntry } from "./session/instruction-entry"
import { SessionInstructions } from "./session/instructions"
import { SessionGenerateNode } from "./session/generate-node"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
@@ -81,6 +82,7 @@ const locationServiceNodes = [
Form.node,
QuestionV2.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+2
View File
@@ -10,6 +10,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
@@ -151,6 +152,7 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+51 -7
View File
@@ -33,10 +33,12 @@ import { LocationServiceMap } from "./location-service-map"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionPending } from "./session/pending"
import { SessionGenerate } from "./session/generate"
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"
@@ -175,6 +177,7 @@ export type Error =
| CommandV2.NotFoundError
| CommandV2.EvaluationError
| MessageNotFoundError
| SessionGenerate.Error
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -243,6 +246,11 @@ export interface Interface {
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -531,9 +539,13 @@ 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))
const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe(
Effect.provideService(FSUtil.Service, fs),
)
// 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 messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
type: "user",
@@ -564,6 +576,11 @@ const layer = Layer.effect(
}),
),
),
generate: Effect.fn("V2Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("V2Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -859,10 +876,13 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
}
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
@@ -872,6 +892,7 @@ 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:")
? {
@@ -900,9 +921,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
.join("\n"),
)
: resolved.bytes
return FileAttachment.create({
data: Base64.make(Buffer.from(content).toString("base64")),
const normalized = yield* normalizeImageAttachment(
input,
Base64.make(Buffer.from(content).toString("base64")),
mime,
image,
)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
@@ -910,6 +937,23 @@ 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),
@@ -0,0 +1,69 @@
export * as SessionGenerateNode from "./generate-node"
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionContext } from "./context"
import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const layer = Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: {},
})
return (yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: [],
toolChoice: "none",
}),
)).text
}),
})
}),
)
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
+21
View File
@@ -0,0 +1,21 @@
export * as SessionGenerate from "./generate"
import type { LLMError } from "@opencode-ai/ai"
import { Context, type Effect } from "effect"
import type { Instructions } from "../instructions"
import type { AgentNotFoundError } from "./error"
import type { SessionRunnerModel } from "./runner/model"
import type { SessionSchema } from "./schema"
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError
export interface Interface {
/** Generates text from current Session context without mutating the Session. */
readonly generate: (input: {
readonly sessionID: SessionSchema.ID
readonly prompt: string
}) => Effect.Effect<string, Error>
}
/** Location-scoped transient generation from Session context. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
+36 -8
View File
@@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
),
)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
decodeMessageRow,
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -75,10 +79,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
return yield* db
.transaction(() =>
Effect.gen(function* () {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
const messages = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
const messages = yield* messageEntries(db, sessionID)
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
return {
initial: assembled.initial,
@@ -89,6 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
.pipe(Effect.orDie)
})
export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
)
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
return {
initial: assembled.initial,
messages: entries.map((entry) => entry.message),
instructionUpdate: assembled.update,
}
}),
)
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
+83 -23
View File
@@ -29,12 +29,11 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const admission = yield* Instructions.diff(observed, stored?.current_values)
const result = yield* observeAgainst(observed, stored?.current_values)
return {
sessionID,
initial: !stored,
current: Instructions.applyHashDelta(stored?.current_values ?? {}, admission.delta),
...admission,
...result,
}
})
@@ -123,28 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const folded = fold(yield* instructionEvents(db, sessionID))
if (!folded) {
const state = yield* stateFromEvents(db, sessionID)
if (!state) {
yield* reset(db, sessionID)
return undefined
}
const state = {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
yield* db
.insert(InstructionStateTable)
.values(state)
.onConflictDoUpdate({
target: InstructionStateTable.session_id,
set: {
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
epoch_start: state.epoch_start,
through_seq: state.through_seq,
initial_values: state.initial_values,
current_values: state.current_values,
},
})
.run()
@@ -152,13 +144,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
return state
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
const assembleState = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
state: typeof InstructionStateTable.$inferSelect,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
const updates = rows.map((row) => ({
row,
@@ -188,7 +179,49 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* (
})
values = Instructions.applyDelta(values, delta)
}
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const assembled = yield* assembleState(db, sessionID, instructions, state)
return { initial: assembled.initial, updates: assembled.updates }
})
export const preview = Effect.fn("InstructionState.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
observed: Instructions.ReadResult,
) {
const state = yield* readState(db, sessionID)
const result = yield* observeAgainst(observed, state?.current_values)
const blobs = new Map<Instructions.Hash, Schema.Json>(
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
)
if (!state) {
const values = dereference(result.current, blobs)
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
}
const assembled = yield* assembleState(db, sessionID, instructions, state)
return {
initial: assembled.initial,
updates: assembled.updates,
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
}
})
const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) {
const admission = yield* Instructions.diff(observed, previous)
return {
current: Instructions.applyHashDelta(previous ?? {}, admission.delta),
...admission,
}
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -203,7 +236,26 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* rebuild(db, sessionID)
const latest = yield* db
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* stateFromEvents(db, sessionID)
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* stateFromEvents(db, sessionID)
})
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const folded = fold(yield* instructionEvents(db, sessionID))
return folded ? foldedState(sessionID, folded) : undefined
})
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
@@ -211,8 +263,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess
.limit(1)
.get()
.pipe(Effect.orDie)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
@@ -364,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
}, undefined)
}
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
return {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
}
+46 -10
View File
@@ -1,14 +1,14 @@
export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms may perform Effects before returning.
* plugin/config code. Transforms synchronously rebuild derived state.
*/
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
type TransformCallback<DraftApi> = (draft: DraftApi) => void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
export interface Registration {
@@ -34,6 +34,7 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
@@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
@@ -82,9 +87,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Effect.suspend(() => {
const result = transform(draft)
return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void
Effect.sync(() => {
transform(draft)
})
const materialize = Effect.fnUntraced(function* () {
@@ -97,7 +101,39 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
yield* commit(next)
})
const reload = () => semaphore.withPermit(materialize())
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const reload = Effect.fnUntraced(function* () {
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
return yield* Deferred.await(done)
})
const result: Interface<State, DraftApi> = {
get: () => state,
@@ -117,7 +153,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
batch.reloads.add(reload)
batch.reloads.add(materializeReload)
return
}
yield* materialize()
@@ -132,8 +168,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(reload)
else yield* reload()
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
+8 -11
View File
@@ -6,7 +6,6 @@ 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"
@@ -35,7 +34,6 @@ 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
@@ -50,6 +48,12 @@ 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 []
@@ -117,21 +121,14 @@ export const Plugin = {
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
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")
if ("encoding" in content && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
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 Image.DecodeError ||
error instanceof Image.SizeError
error instanceof ReadToolFileSystem.MediaIngestLimitError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
+45 -8
View File
@@ -3,6 +3,7 @@ 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"
@@ -57,6 +58,40 @@ 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
@@ -84,10 +119,11 @@ const registryLayer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) =>
input.progress?.({
structured: update.structured,
content: (update.content ?? []).map((part) =>
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
@@ -97,7 +133,8 @@ const registryLayer = Layer.effect(
name: part.name,
},
),
}) ?? Effect.void,
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
},
).pipe(
Effect.map((output) => ({ output })),
@@ -115,7 +152,7 @@ const registryLayer = Layer.effect(
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: pending.output,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
@@ -232,11 +269,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
+26 -4
View File
@@ -77,10 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental:
info.experimental?.subagent_depth === undefined
? undefined
: { subagent_depth: info.experimental.subagent_depth },
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
@@ -88,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
? []
: [
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
...info.enabled_providers.map((resource) => ({
action: "provider.use" as const,
resource,
effect: "allow" as const,
})),
]),
...(info.disabled_providers ?? []).map((resource) => ({
action: "provider.use" as const,
resource,
effect: "deny" as const,
})),
]
if (info.experimental?.subagent_depth === undefined && !policies.length) return
return {
subagent_depth: info.experimental?.subagent_depth,
policies: policies.length ? policies : undefined,
}
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
+35 -7
View File
@@ -1,10 +1,12 @@
export * as WellKnown from "./wellknown"
import { Integration } from "@opencode-ai/schema/integration"
import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { isDeepStrictEqual } from "node:util"
import { makeGlobalNode } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { EventV2 } from "./event"
import { KV } from "./kv"
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
@@ -43,14 +45,18 @@ export interface Entry {
export interface Interface {
readonly entries: () => Effect.Effect<readonly Entry[], Error>
readonly snapshot: () => readonly Entry[]
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
readonly changes: Stream.Stream<void>
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
export const Event = {
Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }),
}
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
@@ -97,8 +103,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const kv = yield* KV.Service
const events = yield* EventV2.Service
const cache = yield* Ref.make(new Map<string, Entry>())
const changes = yield* PubSub.unbounded<void>()
const lock = Semaphore.makeUnsafe(1)
const load = Effect.fn("WellKnown.load")(function* () {
@@ -117,9 +123,32 @@ const layer = Layer.effect(
return entries
})
const refresh = Effect.fn("WellKnown.refresh")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, (origin) =>
inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
),
)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
yield* Ref.set(cache, next)
yield* events.publish(Event.Updated, {})
return true
}),
)
})
return Service.of({
entries: load,
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
refresh,
add: Effect.fn("WellKnown.add")(function* (value) {
return yield* lock.withPermit(
Effect.gen(function* () {
@@ -131,7 +160,7 @@ const layer = Layer.effect(
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
return entry
}),
)
@@ -151,11 +180,10 @@ const layer = Layer.effect(
next.delete(origin)
return next
})
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
}),
)
}),
changes: Stream.fromPubSub(changes),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
}),
@@ -163,4 +191,4 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] })
+3 -1
View File
@@ -2,11 +2,13 @@ export * as WellKnownPlugin from "./plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { WellKnown } from "../wellknown"
export const Plugin = define({
id: "opencode.wellknown",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const wellknown = yield* WellKnown.Service
yield* wellknown.entries().pipe(Effect.orDie)
yield* ctx.integration.transform((draft) => {
@@ -26,7 +28,7 @@ export const Plugin = define({
})
})
})
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() => ctx.integration.reload()),
Effect.forkScoped({ startImmediately: true }),
)
+4 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -76,7 +77,9 @@ describe("AgentV2", () => {
)
description = "New description"
hidden = false
yield* agent.reload()
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -261,7 +262,9 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.default())?.id).toBe(old)
configured = false
yield* catalog.reload()
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
}),
)
+21 -2
View File
@@ -53,9 +53,9 @@ const emptyWellknownNode = makeGlobalNode({
WellKnown.Service.of({
entries: () => Effect.succeed([]),
snapshot: () => [],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: () => Effect.die("unused Wellknown.resolve"),
}),
),
@@ -216,9 +216,9 @@ describe("Config", () => {
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
}),
),
@@ -289,6 +289,25 @@ describe("Config", () => {
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Config.Document({
type: "document",
info: decode({
experimental: {
policies: items.map((item) => ({ action: "provider.use", ...item })),
},
}),
})
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
)
})
describe("ConfigPolicyPlugin.Plugin", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.provider.update(ProviderV2.ID.anthropic, () => {})
catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {})
})
yield* addPlugin(() => [
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
{ effect: "allow", resource: "company-*" },
),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined()
expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined()
}),
)
it.effect("prevents project policy from overriding user-global policy", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => [
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
}),
)
it.live("reloads changed policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => entries)
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
entries = [policies({ effect: "allow", resource: "openai" })]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
)
}),
)
})
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for policy reload")
})
@@ -63,6 +63,11 @@ const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchem
.all()
.pipe(Effect.orDie)
const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) =>
Instructions.read(instructions).pipe(
Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
)
describe("InstructionState", () => {
it.effect("observes each source once without publishing events or inserting blobs", () =>
Effect.gen(function* () {
@@ -227,6 +232,134 @@ describe("InstructionState", () => {
}),
)
it.effect("assembles a fresh private update without repairing a missing cache", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.delete(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Changed context"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(
yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("reads through a stale cache without repairing it", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = "Committed update"
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.update(InstructionStateTable)
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Private update"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled.initial).toBe("Initial context")
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
expect(assembled.update).toBe("Private update")
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("assembles initial instructions without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed("Initial context"))
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Initial context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("retains a committed value when fresh instructions are unavailable", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
const { db, events } = yield* setup(sessionID)
let value: string | Instructions.Unavailable = "Committed context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = Instructions.unavailable
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Committed context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(error.keys).toEqual([Instructions.Key.make("test/context")])
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("keeps prepare equivalent to observe followed by commit", () =>
Effect.gen(function* () {
const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
+7
View File
@@ -0,0 +1,7 @@
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,7 +29,9 @@ 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"
@@ -250,6 +252,7 @@ const it = testEffect(
[PermissionV2.node, permissions],
[EventV2.node, events],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
+303
View File
@@ -0,0 +1,303 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionPendingTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die(new Error("unused")),
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
Effect.sync(() => {
requests.push(request)
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => instruction),
render: {
initial: String,
changed: (_previous, current) => current,
},
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
InstructionBuiltIns.node,
PluginHooks.node,
SessionGenerateNode.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, builtins],
[InstructionDiscovery.node, discovery],
[SkillInstructions.node, skills],
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
)
const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
Effect.all({
sequence: EventV2.latestSequence(db, sessionID),
events: db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
messages: db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie),
pending: db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie),
instructions: db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie),
session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
})
const userTexts = (request: LLMRequest) =>
request.messages.flatMap((message) =>
message.role === "user"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
)
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const agents = yield* AgentV2.Service
const instructionBuiltIns = yield* InstructionBuiltIns.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "generate-test",
directory: "/project",
title: "Generate test",
version: "test",
agent: AgentV2.ID.make("build"),
})
.run()
.pipe(Effect.orDie)
return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, events, instructions } = yield* setup
yield* InstructionState.prepare(db, events, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
text: "{}",
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
input: {},
executed: false,
})
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "system"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Changed context"])
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
it.effect("blocks unavailable initial instructions before generation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = Instructions.unavailable
const { db } = yield* setup
const before = yield* durableState(db, sessionID)
const generate = yield* SessionGenerate.Service
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toEqual([])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
+17 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
@@ -24,7 +24,10 @@ 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[] = []
@@ -49,10 +52,22 @@ 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]],
[
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
],
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
@@ -3,6 +3,7 @@ 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"
@@ -28,7 +29,28 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
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 it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
@@ -255,6 +277,75 @@ 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
+34 -7
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@@ -35,7 +36,7 @@ describe("State", () => {
}),
)
it.effect("runs effectful transforms during every reload", () =>
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
@@ -43,15 +44,15 @@ describe("State", () => {
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) =>
Effect.sync(() => {
editor.add(value)
}),
)
yield* state.transform((editor) => {
editor.add(value)
})
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["second"])
}),
)
@@ -112,4 +113,30 @@ describe("State", () => {
expect(finalized).toBe(2)
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
}),
)
})
+3
View File
@@ -8,7 +8,9 @@ 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"
@@ -84,6 +86,7 @@ const it = testEffect(
[PermissionV2.node, permission],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
+39 -18
View File
@@ -291,6 +291,9 @@ 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" },
@@ -364,7 +367,7 @@ describe("ReadTool", () => {
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
it.effect("drops undecodable image data at settlement", () =>
Effect.gen(function* () {
readResult = {
uri: "file:///truncated.png",
@@ -381,11 +384,17 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
],
})
}),
)
it.effect("rejects oversized images when resizing is disabled", () =>
it.effect("drops oversized images at settlement 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)
@@ -409,14 +418,20 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
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")
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.]" },
],
})
}),
)
@@ -460,7 +475,7 @@ describe("ReadTool", () => {
}),
)
it.effect("enforces max base64 bytes after resize attempts", () =>
it.effect("drops images that cannot fit max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
@@ -481,14 +496,20 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
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")
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.]" },
],
})
}),
)
+3
View File
@@ -12,7 +12,9 @@ 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"
@@ -90,6 +92,7 @@ describe("SkillTool", () => {
[PermissionV2.node, permission],
[SkillV2.node, skills],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
)
+3
View File
@@ -11,7 +11,9 @@ 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({
@@ -50,6 +52,7 @@ 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,7 +10,9 @@ 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({
@@ -138,6 +140,7 @@ const it = testEffect(
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
),
)
+39 -2
View File
@@ -3,11 +3,12 @@ import { Effect, Fiber, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { KV } from "@opencode-ai/core/kv"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { testEffect } from "./lib/effect"
const it = testEffect(FetchHttpClient.layer)
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node])))
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node])))
it.live("loads embedded and remote configuration", () =>
Effect.acquireUseRelease(
@@ -65,7 +66,10 @@ serviceIt.live("persists sources in one KV value", () =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const kv = yield* KV.Service
const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const entry = yield* wellknown.add(`${server.url.origin}/`)
expect(entry.origin).toBe(server.url.origin)
@@ -80,3 +84,36 @@ serviceIt.live("persists sources in one KV value", () =>
(server) => Effect.promise(() => server.stop(true)),
),
)
serviceIt.live("refreshes changed manifests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
let command = "first"
return {
server: Bun.serve({
port: 0,
fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }),
}),
update: () => {
command = "second"
},
}
}),
({ server, update }) =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const events = yield* EventV2.Service
yield* wellknown.add(server.url.origin)
expect(yield* wellknown.refresh()).toBe(false)
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
update()
expect(yield* wellknown.refresh()).toBe(true)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
+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", "Show or hide activity"),
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
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"),
+22 -11
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event"
@@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const { theme, themeV2 } = useTheme()
const { theme, themeV2, mode } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
themeV2.hue.accent(500),
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
theme.info,
])
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
[
themeV2.hue.blue(step),
themeV2.hue.purple(step),
themeV2.hue.green(step),
themeV2.hue.orange(step),
themeV2.hue.red(step),
themeV2.hue.cyan(step),
],
(first, second) => first.equals(second),
)
})
return {
list() {
return agents()
@@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const slots = createMemo(() => {
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
const existing = new Set(
data.session
.list()
.filter((x) => x.parentID === undefined)
.map((x) => x.id),
)
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})
@@ -50,8 +50,9 @@ function Mcp(props: { context: Plugin.Context }) {
}
function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const { themeV2, mode, setMode } = useTheme()
const dimensions = useTerminalDimensions()
const modeLabel = createMemo(() => (mode() === "dark" ? "Switch to light" : "Switch to dark"))
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
@@ -72,10 +73,16 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
maxWidth={Math.max(
2,
dimensions().width - 10 - Bun.stringWidth(InstallationVersion) - Bun.stringWidth(modeLabel()) - mcpWidth(),
)}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
</box>
<box flexShrink={0}>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box>
@@ -93,13 +93,13 @@ export function Composer(props: ComposerProps) {
mode: "composer",
enabled: () => props.open,
commands: [
{ 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: "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: "<leader>down",
title: "Hide activity",
group: "Activity",
title: "Toggle composer",
group: "Composer",
run: close,
},
],
@@ -57,7 +57,7 @@ export function ShellTab(props: { sessionID: string }) {
{
id: "composer.shell.up",
title: "Previous shell",
group: "Activity",
group: "Composer",
bind: "up",
run() {
const list = entries()
@@ -68,7 +68,7 @@ export function ShellTab(props: { sessionID: string }) {
{
id: "composer.shell.down",
title: "Next shell",
group: "Activity",
group: "Composer",
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: "Activity",
group: "Composer",
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: "Activity",
group: "Composer",
bind: "up",
run() {
const list = entries()
@@ -161,7 +161,7 @@ export function SubagentsTab(props: { sessionID: string }) {
{
id: "composer.subagent.down",
title: "Next subagent",
group: "Activity",
group: "Composer",
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: "Activity",
group: "Composer",
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: "Activity",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
+12 -7
View File
@@ -740,7 +740,7 @@ export function Session() {
},
},
{
title: composer.open || !!session()?.parentID ? "Hide activity" : "Show activity",
title: "Toggle subagent picker",
name: "session.child.first",
category: "Session",
run: () => {
@@ -2548,10 +2548,8 @@ function WebSearch(props: ToolProps) {
function Subagent(props: ToolProps) {
const { navigate } = useRoute()
const data = useData()
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 sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
@@ -2569,16 +2567,23 @@ function Subagent(props: ToolProps) {
if (id) navigate({ type: "session", sessionID: id })
}}
status={
input().background === true || metadata().status === "running" ? (
isBackgroundSubagent(props.metadata, props.part.state.status) ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
>
{`${Locale.titlecase(stringValue(input().agent) ?? stringValue(input().subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
{`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.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}`
}
@@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={themeV2.text.feedback.warning()}
borderColor={themeV2.background.action.primary("focused")}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
+1
View File
@@ -25,6 +25,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step],
interactive: (step: HueStep) => current().hue.interactive[step],
neutral: (step: HueStep) => current().hue.neutral[step],
}
const text = Object.assign(() => current().text.default, {
+12 -10
View File
@@ -93,6 +93,7 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
@@ -108,7 +109,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.600",
$selected: "$hue.interactive.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
@@ -125,7 +126,7 @@ export const DEFAULT_THEME = {
},
action: {
primary: {
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
default: "$hue.interactive.600", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.300",
},
secondary: {
@@ -140,7 +141,7 @@ export const DEFAULT_THEME = {
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
@@ -196,14 +197,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
@@ -298,6 +299,7 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
@@ -313,7 +315,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.500",
$selected: "$hue.interactive.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
@@ -330,7 +332,7 @@ export const DEFAULT_THEME = {
},
action: {
primary: {
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
default: "$hue.interactive.500", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.800",
},
secondary: {
@@ -345,7 +347,7 @@ export const DEFAULT_THEME = {
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
@@ -401,14 +403,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
-1
View File
@@ -157,7 +157,6 @@ function resolveHue(definition: HueDefinition) {
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name]
if (typeof value === "string") {
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name])
+24 -22
View File
@@ -6,7 +6,7 @@ export type HueStep = Schema.Schema.Type<typeof HueStep>
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "neutral"])
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
@@ -41,33 +41,35 @@ const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueScaleDefinition,
red: HueScaleDefinition,
orange: HueScaleDefinition,
yellow: HueScaleDefinition,
green: HueScaleDefinition,
cyan: HueScaleDefinition,
blue: HueScaleDefinition,
purple: HueScaleDefinition,
accent: HueAliasDefinition,
neutral: HueAliasDefinition,
gray: HueValueDefinition,
red: HueValueDefinition,
orange: HueValueDefinition,
yellow: HueValueDefinition,
green: HueValueDefinition,
cyan: HueValueDefinition,
blue: HueValueDefinition,
purple: HueValueDefinition,
accent: HueValueDefinition,
interactive: HueValueDefinition,
neutral: HueValueDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueScaleDefinition),
red: Schema.optional(HueScaleDefinition),
orange: Schema.optional(HueScaleDefinition),
yellow: Schema.optional(HueScaleDefinition),
green: Schema.optional(HueScaleDefinition),
cyan: Schema.optional(HueScaleDefinition),
blue: Schema.optional(HueScaleDefinition),
purple: Schema.optional(HueScaleDefinition),
accent: Schema.optional(HueAliasDefinition),
neutral: Schema.optional(HueAliasDefinition),
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
+133 -47
View File
@@ -1,9 +1,16 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index"
import { HueStep } from "./schema"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
const minimumChroma = 0.03
const lightThreshold = 0.6
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
@@ -18,33 +25,48 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
return {
hue: {
...DEFAULT_THEME[mode].hue,
accent: hueScale(theme.secondary),
gray: neutralScale(theme, mode),
...Object.fromEntries(
chromaticHues.map((name) => {
const match = hues[name]
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
}),
),
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
neutral: "$hue.gray",
},
text: {
default: color("text"),
subdued: color("textMuted"),
default: text,
subdued: textMuted,
action: {
primary: {
default: selected,
$disabled: color("textMuted"),
default: "$text.default",
$disabled: textMuted,
$focused: selected,
},
secondary: {
default: "$text.default",
$disabled: color("textMuted"),
$disabled: textMuted,
},
destructive: { default: destructive, $disabled: color("textMuted") },
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
default: color("text"),
$focused: color("primary"),
$pressed: color("primary"),
$disabled: color("textMuted"),
$selected: color("primary"),
default: text,
$focused: primary,
$pressed: primary,
$disabled: textMuted,
$selected: primary,
},
feedback: {
error: { default: color("error") },
@@ -54,13 +76,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
},
},
background: {
default: color("background"),
default: background,
surface: {
offset: color("backgroundPanel"),
overlay: color("backgroundMenu"),
offset: backgroundPanel,
overlay: backgroundMenu,
},
action: {
primary: { default: color("primary"), $focused: color("primary") },
primary: { default: "transparent", $focused: primary },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
@@ -131,21 +153,44 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
"@context:elevated": {
background: {
default: "$background.surface.offset",
action: {
primary: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
}
}
function inferHues(theme: Theme, mode: "light" | "dark") {
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
>((result, color) => {
const value = toOklch(color)
if (ambiguous(color, value.c)) return result
const anchor = inferenceAnchor(value.l)
const nearest = chromaticHues
.map((name) => ({
name,
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
}))
.sort((first, second) => first.distance - second.distance)[0]
const current = result[nearest.name]
if (current && current.distance <= nearest.distance) return result
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
}, {})
}
function inferenceAnchor(lightness: number): HueStep {
return lightness >= lightThreshold ? 300 : 700
}
function hueDistance(first: number, second: number) {
const difference = Math.abs(first - second)
return Math.min(difference, 360 - difference)
}
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
return color.toInts()[3] === 0 || chroma < minimumChroma
}
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
const defs = theme.defs ?? {}
@@ -192,28 +237,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255)
}
function hueScale(color: RGBA) {
return {
100: mix(color, 255, 0.8),
200: mix(color, 255, 0.6),
300: mix(color, 255, 0.4),
400: mix(color, 255, 0.2),
500: hex(color),
600: mix(color, 0, 0.15),
700: mix(color, 0, 0.3),
800: mix(color, 0, 0.45),
900: mix(color, 0, 0.6),
}
function hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 900 : 100
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
h: value.h,
})
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
}),
) as Record<HueStep, string>
}
function mix(color: RGBA, target: number, amount: number) {
const [r, g, b, a] = color.toInts()
return hexInts(
Math.round(r + (target - r) * amount),
Math.round(g + (target - g) * amount),
Math.round(b + (target - b) * amount),
a,
)
function neutralScale(theme: Theme, mode: "light" | "dark") {
const anchors = neutralAnchors(theme, mode)
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
const upper = anchors.find((anchor) => anchor.step > step)!
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
}
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
}
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = toOklch(first)
const end = toOklch(second)
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
const generated = oklchToHex({
l: start.l + (end.l - start.l) * amount,
c: start.c + (end.c - start.c) * amount,
h: start.h + hue * amount,
})
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
}
function toOklch(color: RGBA) {
const [red, green, blue] = color.toInts()
return rgbToOklch(red / 255, green / 255, blue / 255)
}
function hex(color: RGBA) {
@@ -221,10 +304,13 @@ function hex(color: RGBA) {
}
function hexInts(r: number, g: number, b: number, a: number) {
const byte = (value: number) => value.toString(16).padStart(2, "0")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}
function byte(value: number) {
return value.toString(16).padStart(2, "0")
}
function ansi(code: number) {
if (code < 16) {
const colors = [
@@ -4,6 +4,7 @@ import { testRender, type JSX } from "@opentui/solid"
import {
formatSubagentRetry,
InlineToolRow,
isBackgroundSubagent,
parseApplyPatchFiles,
parseDiagnostics,
parseQuestionAnswers,
@@ -201,6 +202,13 @@ 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 activity with down", () => {
test("opens the subagent picker with down", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>down" }])
+37
View File
@@ -0,0 +1,37 @@
# TUI theme screenshot gallery
This gallery runs one repeatable OpenCode Drive workflow for every V1 theme in a directory. Each theme runs in
light and dark mode and produces a flat set of screenshots covering the home screen, markdown, permission prompt,
form prompt, and session switcher.
It expects `opencode-drive` to be installed globally and available on `PATH`.
The checked-in fixtures include selected built-in OpenCode themes and community themes from
[`vaprdev/opencode-themes`](https://github.com/vaprdev/opencode-themes). Theme-specific attribution and licenses
remain documented in that source repository.
## Run
Place theme JSON files in `themes/`, then run from the repository root:
```sh
bun packages/tui/test/theme/gallery/run.ts
```
Custom input and output directories can be passed as positional arguments:
```sh
bun packages/tui/test/theme/gallery/run.ts ./my-themes ./theme-screenshots
```
Output files are named `<theme>-<mode>-<state>.png` in one flat directory. Existing files with the same names are
overwritten. Runs are sequential so each isolated OpenCode Drive instance owns its own ports and artifacts.
The current TUI discovers V1 theme files and migrates them to V2 at runtime. Native V2 JSON files are reported as
unsupported and make the runner exit nonzero; they are not converted or silently rendered with a fallback theme.
Before changing the scenario, typecheck it with:
```sh
opencode-drive check packages/tui/test/theme/gallery/scenario.ts
```
+79
View File
@@ -0,0 +1,79 @@
import path from "node:path"
import { mkdir } from "node:fs/promises"
const root = path.resolve(import.meta.dir, "../../../../..")
const themes = path.resolve(Bun.argv[2] ?? path.join(import.meta.dir, "themes"))
const screenshots = path.resolve(Bun.argv[3] ?? path.join(import.meta.dir, "screenshots"))
const scenario = path.join(import.meta.dir, "scenario.ts")
const files = await Array.fromAsync(new Bun.Glob("**/*.json").scan({ cwd: themes, absolute: true }))
if (!files.length) {
console.error(`No JSON themes found in ${themes}`)
process.exit(1)
}
await mkdir(screenshots, { recursive: true })
const failures: string[] = []
const slugs = new Set<string>()
for (const file of files.sort()) {
const source = await Bun.file(file).json().catch(() => undefined)
const slug = path.basename(file, ".json").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
if (!slug) {
failures.push(`${file}: filename does not produce a usable theme prefix`)
continue
}
if (slugs.has(slug)) {
failures.push(`${file}: duplicate theme prefix "${slug}"`)
continue
}
slugs.add(slug)
if (!isRecord(source)) {
failures.push(`${file}: invalid JSON theme object`)
continue
}
if (source.version === 2) {
failures.push(`${file}: native V2 theme loading is not supported by the TUI yet`)
continue
}
if (!isRecord(source.theme)) {
failures.push(`${file}: expected a V1 theme with a "theme" object`)
continue
}
for (const mode of ["light", "dark"] as const) {
const name = `theme-gallery-${slug}-${mode}-${process.pid}`
console.log(`\n[${slug}/${mode}] capturing screenshots`)
const child = Bun.spawn(
["opencode-drive", "start", "--name", name, "--script", scenario, "--dev", root],
{
cwd: root,
env: {
...process.env,
OPENCODE_THEME_GALLERY_FILE: file,
OPENCODE_THEME_GALLERY_MODE: mode,
OPENCODE_THEME_GALLERY_OUTPUT: screenshots,
OPENCODE_THEME_GALLERY_SLUG: slug,
},
stdout: "inherit",
stderr: "inherit",
},
)
const exit = await child.exited
if (exit !== 0) failures.push(`${file} (${mode}): OpenCode Drive exited with ${exit}`)
}
}
if (failures.length) {
console.error("\nTheme gallery completed with errors:")
failures.forEach((failure) => console.error(`- ${failure}`))
process.exit(1)
}
console.log(`\nScreenshots written to ${screenshots}`)
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
+123
View File
@@ -0,0 +1,123 @@
import path from "node:path"
import { mkdir } from "node:fs/promises"
import { defineScript, wait } from "opencode-drive"
const file = required("OPENCODE_THEME_GALLERY_FILE")
const mode = required("OPENCODE_THEME_GALLERY_MODE")
const output = required("OPENCODE_THEME_GALLERY_OUTPUT")
const slug = required("OPENCODE_THEME_GALLERY_SLUG")
const themeName = `gallery-${slug}`
export default defineScript({
async setup({ fs, config }) {
await fs.writeFile("README.md", "# Theme gallery fixture\n\nA stable project for OpenCode TUI screenshots.\n")
await fs.writeFile("src/example.ts", "export const palette = ['neutral', 'accent', 'interactive']\n")
await fs.writeFile(`.opencode/themes/${themeName}.json`, await Bun.file(file).text())
await fs.writeFile(
".opencode/cli.json",
`${JSON.stringify({
theme: { name: themeName, mode },
}, undefined, 2)}\n`,
)
config.permissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
]
},
async run({ llm, ui }) {
await mkdir(output, { recursive: true })
llm.title((_request, index) => (index === 0 ? "Theme gallery" : `Theme gallery ${index + 1}`))
await ui.waitFor((state) => state.focused.editor)
await ui.waitFor("local")
await wait(750)
await capture(ui, "01-home")
await ui.submit("Show me a compact markdown theme specimen")
await llm.send(
llm.reasoning("I will provide a stable markdown sample for the theme gallery."),
llm.text(
[
"# Theme Review",
"",
"A compact response with **strong text**, *emphasis*, and an `inline token`.",
"",
"> Good themes preserve hierarchy without losing contrast.",
"",
"- Neutral surfaces",
"- Interactive accents",
"- Success, warning, and error feedback",
"",
"```ts",
"const mode = 'gallery'",
"```",
].join("\n"),
),
)
await ui.waitFor("Theme Review")
await capture(ui, "02-markdown")
const permission = llm.send(
llm.toolCall({
id: "theme-gallery-permission",
index: 0,
name: "shell",
input: { command: "printf 'theme gallery permission'" },
}),
)
await ui.submit("Run a protected command so I can inspect the permission prompt")
await ui.waitFor("Permission required")
await capture(ui, "03-permission")
await ui.enter()
await permission
await llm.send(llm.text("The protected command completed successfully."))
await ui.waitFor("completed successfully")
const form = llm.send(
llm.toolCall({
id: "theme-gallery-question",
index: 0,
name: "question",
input: {
questions: [
{
header: "Theme direction",
question: "Which direction should this theme emphasize?",
options: [
{ label: "Balanced", description: "Keep surfaces and accents evenly weighted" },
{ label: "Expressive", description: "Give accent colors more visual presence" },
{ label: "Quiet", description: "Favor neutral surfaces and restrained contrast" },
],
},
],
},
}),
)
await ui.submit("Ask me for a theme direction")
await ui.waitFor("Which direction should this theme emphasize?")
await capture(ui, "04-form")
await ui.enter()
await form
await llm.send(llm.text("The theme review form was submitted."))
await ui.waitFor("form was submitted")
await ui.submit("/new")
await ui.waitFor((state) => state.focused.editor)
await ui.submit("Create a second session for the session switcher gallery")
await llm.send(llm.text("This second session makes the switcher state visible."))
await ui.submit("/sessions")
await ui.waitFor("Sessions")
await capture(ui, "05-session-switcher")
},
})
async function capture(ui: { screenshot(name?: string): Promise<string> }, state: string) {
const source = await ui.screenshot(`${slug}-${mode}-${state}`)
await Bun.write(path.join(output, `${slug}-${mode}-${state}.png`), Bun.file(source))
}
function required(name: string) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required`)
return value
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

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