Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8e1ecafc | |||
| 4885c3a5d3 | |||
| a7cf21e157 | |||
| e4a16830f1 | |||
| 103f764624 | |||
| 7a7075d86f | |||
| c764732aea | |||
| 72af084dc1 | |||
| 65a42fd549 | |||
| ad8e6b1fb6 | |||
| 041cda905d | |||
| 2ad6c42143 | |||
| 60c7f847c1 | |||
| 7ab8a08efa | |||
| 7388e69b5c |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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") {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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],
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.]" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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" }])
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 41 KiB |