mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 05:46:23 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9569ddc6a0 | ||
|
|
32ae1062f5 | ||
|
|
1594bdab43 | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 | ||
|
|
fc49b16584 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +514,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +560,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +575,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +644,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +659,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +704,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +726,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +761,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +815,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +834,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +880,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +894,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +929,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +964,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -168,7 +168,7 @@ else
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -88,14 +88,25 @@ runtime.catalog() // structured tool descriptions
|
||||
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
|
||||
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
|
||||
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
|
||||
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
|
||||
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
### `Extension.make`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array 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.
|
||||
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## 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, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `if`/`else` and conditional expressions.
|
||||
- [x] `switch`, including default clauses and fallthrough.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
|
||||
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -450,7 +450,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
|
||||
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
|
||||
iteration is live and sorted by name, with `set-cookie` values kept apart.
|
||||
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
|
||||
- [ ] `Request`, `Response`, and `Blob`.
|
||||
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -460,13 +466,15 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
(`fetch.json`). Like any program function it vanishes at the data boundary.
|
||||
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
|
||||
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
export type {
|
||||
CallResult,
|
||||
ExtensionInvocation,
|
||||
Hooks,
|
||||
ToolCall,
|
||||
ToolDescription,
|
||||
ToolInvocation,
|
||||
} from "./tool-runtime.js"
|
||||
/** Signature-construction helpers for host-owned catalog instructions. */
|
||||
export { searchSignature, toolExpression } from "./tool-runtime.js"
|
||||
|
||||
@@ -34,9 +41,11 @@ export type ResolvedExecutionLimits = {
|
||||
}
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = {
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
|
||||
hooks?: ToolRuntime.Hooks<Services<Provided>>
|
||||
/** Host functions exposed as globals; see `Extension.make`. */
|
||||
extensions?: ReadonlyArray<Extension>
|
||||
/** Resource limits enforced on each execution. */
|
||||
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
}
|
||||
}
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
get catalog() {
|
||||
return prepared.catalog
|
||||
},
|
||||
execute: (code) =>
|
||||
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -69,6 +70,7 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
|
||||
code: string,
|
||||
prepared: ToolRuntime.Prepared<R>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
hooks: ToolRuntime.ToolCallHooks<R>,
|
||||
hooks: ToolRuntime.Hooks<R>,
|
||||
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
|
||||
): Effect.Effect<Result, never, R> => {
|
||||
if (code.trim().length === 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Extension } from "../extension.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
type Native,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -49,6 +50,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
@@ -96,6 +98,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
@@ -128,26 +131,35 @@ export const extensionGlobals = <R>(
|
||||
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
|
||||
}
|
||||
|
||||
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
|
||||
// rejects with crosses out, so the program catches what the author threw.
|
||||
const wrap = (value: Function, label: string): Native<R> =>
|
||||
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
|
||||
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
|
||||
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
|
||||
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
|
||||
fn<R>(builtins, value.name, value.length, (_, values) => {
|
||||
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
|
||||
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const hooks = ctx.tools.hooks
|
||||
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
|
||||
(describe === undefined
|
||||
? run
|
||||
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
|
||||
).pipe(
|
||||
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
|
||||
Effect.map((settled) => fromHost(settled, label)),
|
||||
)
|
||||
let result: unknown
|
||||
try {
|
||||
result = value.apply(undefined, converted)
|
||||
result = value.apply(undefined, args)
|
||||
} catch (reason) {
|
||||
return Effect.fail(thrown(reason))
|
||||
return settle(Effect.fail(reason))
|
||||
}
|
||||
if (!(result instanceof Promise)) return fromHost(result, label)
|
||||
return ctx.pending.create(
|
||||
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
|
||||
)
|
||||
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
|
||||
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
|
||||
})
|
||||
|
||||
return extensions.flatMap((extension) =>
|
||||
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
|
||||
Object.entries(extension.globals).map(
|
||||
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -80,6 +81,7 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -653,7 +654,7 @@ class Frame<R> {
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -756,9 +757,11 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
@@ -1848,6 +1851,7 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,6 +29,7 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -156,6 +156,15 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -181,13 +190,14 @@ export class Bytes extends Obj {
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
@@ -85,6 +86,7 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { PendingThrow, typeError } from "../interpreter/model.js"
|
||||
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
|
||||
import { applyCollectionCallback } from "../interpreter/callback.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { readPairs } from "./url.js"
|
||||
|
||||
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
|
||||
const attempt = <T>(run: () => T): T => {
|
||||
try {
|
||||
return run()
|
||||
} catch (error) {
|
||||
if (error instanceof PendingThrow) throw error
|
||||
throw typeError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
|
||||
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
|
||||
if (init === undefined) return Effect.succeed(wrap(new Headers()))
|
||||
if (init instanceof HeadersObj) return Effect.succeed(wrap(new Headers(init.headers)))
|
||||
return Effect.gen(function* () {
|
||||
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
|
||||
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
|
||||
if (!(init instanceof Obj) || isRuntimeReference(init)) {
|
||||
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
|
||||
}
|
||||
return wrap(
|
||||
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Headers
|
||||
const headers = constructor<R>(builtins, proto, {
|
||||
name: "Headers",
|
||||
call: requiresNew("Headers"),
|
||||
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
attempt(() => self(thisValue, "append").headers.append(arg(args, 0), arg(args, 1)))
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
attempt(() => self(thisValue, "delete").headers.delete(arg(args, 0)))
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
return attempt(() => self(thisValue, "get").headers.get(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
return attempt(() => self(thisValue, "has").headers.has(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
attempt(() => self(thisValue, "set").headers.set(arg(args, 0), arg(args, 1)))
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return headers
|
||||
}
|
||||
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -126,6 +124,26 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
|
||||
}
|
||||
})
|
||||
|
||||
/** Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. */
|
||||
export const readPairs = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
label: string,
|
||||
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) return undefined
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
return pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
|
||||
}
|
||||
})
|
||||
|
||||
const constructURLSearchParams = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
@@ -139,20 +157,8 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
|
||||
}
|
||||
}
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "[object Set]"
|
||||
if (value instanceof URLObj) return value.url.href
|
||||
if (value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return "[object Headers]"
|
||||
if (value instanceof Bytes) return value.bytes.join(",")
|
||||
if (value instanceof ErrorObj) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
|
||||
@@ -36,26 +36,48 @@ export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
/** A tool call the program is making, with its decoded input. */
|
||||
export type ToolInvocation = { readonly name: string; readonly input: unknown }
|
||||
|
||||
/** A call the program is making to an extension global, with its arguments. */
|
||||
export type ExtensionInvocation = {
|
||||
readonly extension: string
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly args: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly message?: string
|
||||
/** How a call ended; `after` hooks observe it and cannot change it. */
|
||||
export type CallResult =
|
||||
| { readonly status: "success"; readonly value: unknown }
|
||||
| { readonly status: "failure"; readonly error: unknown }
|
||||
| { readonly status: "interrupted" }
|
||||
|
||||
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
|
||||
export type Hooks<R = never> = {
|
||||
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "extension.after"?:
|
||||
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type ToolCallHooks<R = never> = {
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
|
||||
export const hooked = <Call, A, R>(
|
||||
call: Call,
|
||||
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
|
||||
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
|
||||
run: Effect.Effect<A, unknown, R>,
|
||||
): Effect.Effect<A, unknown, R> => {
|
||||
const observed =
|
||||
after === undefined
|
||||
? run
|
||||
: Effect.onExit(run, (exit) => {
|
||||
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
|
||||
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
|
||||
})
|
||||
return before === undefined ? observed : Effect.andThen(before(call), observed)
|
||||
}
|
||||
|
||||
export type ToolDescription = {
|
||||
@@ -144,15 +166,24 @@ const flattenTools = <R>(
|
||||
]
|
||||
}
|
||||
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
signature: isEmptyInput(visible.tool)
|
||||
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
|
||||
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
|
||||
let signature: string | undefined
|
||||
return {
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
get signature() {
|
||||
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
|
||||
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
|
||||
return (signature ??= [
|
||||
toolExpression(visible.path),
|
||||
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
|
||||
`: Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
].join(""))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
|
||||
/** Tools indexed once per runtime, with discovery materialized on demand. */
|
||||
export type Prepared<R = never> = {
|
||||
readonly root: ToolNode<R>
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
|
||||
const root = toolTrie(tools)
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
|
||||
let searchIndex: ReadonlyArray<SearchEntry> | undefined
|
||||
let catalog: ReadonlyArray<ToolDescription> | undefined
|
||||
return {
|
||||
root,
|
||||
catalog: visible.map(describeTool),
|
||||
searchIndex: visible.map(toSearchEntry),
|
||||
get catalog() {
|
||||
return (catalog ??= this.searchIndex.map((entry) => entry.description))
|
||||
},
|
||||
get searchIndex() {
|
||||
// Executing known tools only needs the trie. Render discovery when it is actually read,
|
||||
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
|
||||
return (searchIndex ??= flattenTools(root)
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(toSearchEntry))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
|
||||
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly hooks: Hooks<R>
|
||||
readonly execute: (
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<Json | undefined>,
|
||||
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
|
||||
export const make = <R>(
|
||||
prepared: Prepared<R>,
|
||||
maxToolCalls: number | undefined,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
hooks: Hooks<R> = {},
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const root = prepared.root
|
||||
const searchTool = makeSearchTool(prepared.searchIndex)
|
||||
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
const startedAt = Date.now()
|
||||
return effect.pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
|
||||
const error = Cause.squash(exit.cause)
|
||||
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
|
||||
return onEnd({ ...call, durationMs, outcome: "failure", message })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const recordCall = (call: ToolCall): void => {
|
||||
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
|
||||
@@ -371,14 +394,12 @@ export const make = <R>(
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
})
|
||||
const call = { index, name, input }
|
||||
return yield* observeEnd(
|
||||
yield* Effect.sync(() => recordCall({ name }))
|
||||
return yield* hooked(
|
||||
{ name, input },
|
||||
hooks["tool.before"],
|
||||
hooks["tool.after"],
|
||||
Effect.gen(function* () {
|
||||
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
|
||||
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
@@ -400,20 +421,20 @@ export const make = <R>(
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
|
||||
})
|
||||
}),
|
||||
call,
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
calls,
|
||||
hooks,
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
|
||||
execute: (path, args) =>
|
||||
Effect.suspend(() => {
|
||||
const segments = canonicalSegments(path)
|
||||
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
|
||||
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
|
||||
return executeTool("search", searchTool, args)
|
||||
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
|
||||
return executeTool(segments.join("."), resolve(root, path), args)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
|
||||
|
||||
const run = (tool: Tool.Tool<never>) =>
|
||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeMode tool-call observation", () => {
|
||||
test("reports the tools actually invoked with decoded input", async () => {
|
||||
const calls: Array<unknown> = []
|
||||
describe("CodeMode call hooks", () => {
|
||||
const ended = (result: CodeMode.CallResult) => {
|
||||
if (result.status !== "failure") return result.status
|
||||
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
|
||||
}
|
||||
// Every hook logs its event, so the order across tools and extensions is visible.
|
||||
const observe = (events: Array<string>): CodeMode.Hooks => ({
|
||||
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
|
||||
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
|
||||
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
|
||||
"extension.after": (call, result) =>
|
||||
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
|
||||
})
|
||||
|
||||
test("surround the tools actually invoked, with decoded input", async () => {
|
||||
const events: Array<string> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) => Effect.succeed(query),
|
||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
|
||||
}).execute(`
|
||||
const success = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
if (false) await tools.context.lookup({ query: "not called" })
|
||||
return await tools.context.lookup({ query: "deployment failure" })
|
||||
`),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
|
||||
})
|
||||
|
||||
test("observes settled calls with outcome and duration", async () => {
|
||||
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) =>
|
||||
query === "boom"
|
||||
? Effect.fail(toolError("Lookup refused"))
|
||||
: query === "defect"
|
||||
? Effect.die("broken")
|
||||
: Effect.succeed(query),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
|
||||
expect(success.ok).toBe(true)
|
||||
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
|
||||
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
|
||||
expect(failure.ok).toBe(false)
|
||||
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
|
||||
expect(defect.ok).toBe(false)
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
|
||||
"tool.after context.lookup success",
|
||||
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
|
||||
"tool.after context.lookup failure:Lookup refused",
|
||||
])
|
||||
})
|
||||
|
||||
expect(events).toStrictEqual([
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
|
||||
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
|
||||
const events: Array<string> = []
|
||||
const runtime = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "web",
|
||||
globals: {
|
||||
fetch: async (url: string, init?: { method?: string }) => ({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
json: () => 1,
|
||||
}),
|
||||
fail: () => {
|
||||
throw new RangeError("nope")
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
hooks: observe(events),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const res = await fetch("https://a.test/", { method: "POST" })
|
||||
res.json()
|
||||
try { fail() } catch {}
|
||||
return res.url
|
||||
`),
|
||||
)
|
||||
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
|
||||
expect(events).toEqual([
|
||||
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
|
||||
"extension.after fetch success",
|
||||
'extension.before {"extension":"web","name":"fail","args":[]}',
|
||||
"extension.after fail failure:nope",
|
||||
])
|
||||
})
|
||||
|
||||
test("a failing before hook denies the call; the program catches the host's error", async () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
|
||||
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
|
||||
return [denied, await tools.lookup({})]
|
||||
`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
|
||||
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
|
||||
})
|
||||
const refused = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
|
||||
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ToolFailure", message: "lookup is not allowed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
|
||||
const events: Array<string> = []
|
||||
const broken = Tool.make({
|
||||
description: "Broken",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.die("broken"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { broken },
|
||||
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
|
||||
hooks: observe(events),
|
||||
})
|
||||
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
|
||||
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
|
||||
ok: true,
|
||||
value: "done",
|
||||
})
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"broken","input":{}}',
|
||||
"tool.after broken failure:broken",
|
||||
'extension.before {"extension":"slow","name":"forever","args":[]}',
|
||||
"extension.after forever interrupted",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.interrupt,
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes running calls interrupted during completion", async () => {
|
||||
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute('tools.host.call({}); return "done"'),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
|
||||
'tools.host.call({}); return "done"',
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, value: "done" })
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
})
|
||||
|
||||
test("ends calls interrupted during start observation", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Unused",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("unused"),
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.interrupt,
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes calls interrupted by the execution timeout", async () => {
|
||||
const outcomes: Array<string> = []
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Pending",
|
||||
input: Schema.Struct({}),
|
||||
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
limits: { timeoutMs: 10 },
|
||||
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
|
||||
"return await tools.host.call({})",
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
|
||||
expect(outcomes).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
||||
const started: Array<string> = []
|
||||
const ended: Array<string> = []
|
||||
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
|
||||
const observed: Array<CodeMode.ToolInvocation> = []
|
||||
const limited = CodeMode.make({
|
||||
tools,
|
||||
limits: { maxToolCalls: 1 },
|
||||
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
|
||||
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
|
||||
})
|
||||
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
|
||||
expect(started).toEqual(["search"])
|
||||
expect(ended).toEqual(["search:success"])
|
||||
expect(observed).toEqual([{ name: "search", input: {} }])
|
||||
})
|
||||
|
||||
test("search is an opaque, shadowable global like other built-ins", async () => {
|
||||
@@ -1089,7 +1135,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { math: { double: transformed } },
|
||||
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
|
||||
|
||||
@@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
|
||||
})
|
||||
|
||||
test("Headers cross as copies in both directions", async () => {
|
||||
const stored = new Headers({ "X-A": "1" })
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "http",
|
||||
globals: {
|
||||
headers: () => stored,
|
||||
keep: (value: Headers) => {
|
||||
held.push(value)
|
||||
return value
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
held.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
|
||||
expect(stored.get("x-a")).toBe("1")
|
||||
expect(held[0]).toBeInstanceOf(Headers)
|
||||
expect((held[0] as Headers).get("x-a")).toBe("2")
|
||||
})
|
||||
|
||||
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
|
||||
const stored = new Uint8Array([1, 2, 3])
|
||||
const target = CodeMode.make({
|
||||
@@ -192,6 +220,16 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
|
||||
})
|
||||
|
||||
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
|
||||
],
|
||||
})
|
||||
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
|
||||
expect((await failure(`await later()`, target)).location).toBeDefined()
|
||||
})
|
||||
|
||||
test("a class instance cannot come out", async () => {
|
||||
class Other {}
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -635,6 +635,154 @@ describe("URL and URI helpers", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Headers", () => {
|
||||
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
|
||||
headers.append("Accept", "text/html")
|
||||
headers.append("accept", "application/json")
|
||||
headers.set("x-count", "2")
|
||||
headers.delete("x-null")
|
||||
const copy = new Headers(headers)
|
||||
copy.set("content-type", "text/html")
|
||||
return {
|
||||
get: headers.get("content-type"),
|
||||
missing: headers.get("x-missing"),
|
||||
combined: headers.get("ACCEPT"),
|
||||
has: [headers.has("Accept"), headers.has("x-null")],
|
||||
count: headers.get("x-count"),
|
||||
copied: [headers.get("content-type"), copy.get("content-type")],
|
||||
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
|
||||
map: [...new Headers(new Map([["k", "v"]]))],
|
||||
keys: headers.keys(),
|
||||
values: headers.values(),
|
||||
entries: headers.entries(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
get: "text/plain",
|
||||
missing: null,
|
||||
combined: "text/html, application/json",
|
||||
has: [true, false],
|
||||
count: "2",
|
||||
copied: ["text/plain", "text/html"],
|
||||
pairs: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
map: [["k", "v"]],
|
||||
keys: ["accept", "content-type", "x-count"],
|
||||
values: ["text/html, application/json", "text/plain", "2"],
|
||||
entries: [
|
||||
["accept", "text/html, application/json"],
|
||||
["content-type", "text/plain"],
|
||||
["x-count", "2"],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ b: "2", a: "1" })
|
||||
headers.append("Set-Cookie", "x=1")
|
||||
headers.append("set-cookie", "y=2")
|
||||
const seen = []
|
||||
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
|
||||
const [first] = headers
|
||||
function* pairs() { yield* headers }
|
||||
return {
|
||||
seen,
|
||||
first,
|
||||
spread: [...headers],
|
||||
from: Array.from(headers).length,
|
||||
generator: [...pairs()].length,
|
||||
object: Object.fromEntries(headers),
|
||||
cookies: headers.getSetCookie(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
|
||||
first: ["a", "1"],
|
||||
spread: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["set-cookie", "x=1"],
|
||||
["set-cookie", "y=2"],
|
||||
],
|
||||
from: 4,
|
||||
generator: 4,
|
||||
object: { a: "1", b: "2", "set-cookie": "y=2" },
|
||||
cookies: ["x=1", "y=2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
|
||||
const result = await run(`
|
||||
const headers = new Headers({ "X-A": "1", b: "2" })
|
||||
console.log(headers)
|
||||
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
|
||||
`)
|
||||
expect(result.ok && result.value).toEqual({
|
||||
headers: { b: "2", "x-a": "1" },
|
||||
json: '{"headers":{"b":"2","x-a":"1"}}',
|
||||
text: "[object Headers]",
|
||||
type: "object",
|
||||
is: true,
|
||||
})
|
||||
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
|
||||
})
|
||||
|
||||
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function message(run) {
|
||||
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
|
||||
}
|
||||
const headers = new Headers()
|
||||
return [
|
||||
message(() => Headers()),
|
||||
message(() => new Headers(null)),
|
||||
message(() => new Headers(1)),
|
||||
message(() => new Headers("a=1")),
|
||||
message(() => new Headers(new Date())),
|
||||
message(() => new Headers(() => 1)),
|
||||
message(() => new Headers([["name"]])),
|
||||
message(() => new Headers([["a", "b", "c"]])),
|
||||
message(() => new Headers({ "bad name": "x" })),
|
||||
message(() => new Headers({ name: "bad\u0000value" })),
|
||||
message(() => headers.get("invalid\u0100")),
|
||||
message(() => headers.has({})),
|
||||
message(() => headers.set("a", "invalid\u0100")),
|
||||
message(() => headers.append("a")),
|
||||
message(() => headers.forEach()),
|
||||
message(() => headers.forEach(1)),
|
||||
message(() => { const get = headers.get; return get("a") }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"Constructor Headers requires 'new'.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
expect.stringContaining("bad name"),
|
||||
expect.stringContaining("invalid value"),
|
||||
expect.stringContaining("Invalid header name"),
|
||||
expect.stringContaining("[object Object]"),
|
||||
expect.stringContaining("invalid value"),
|
||||
"Headers.append requires 2 arguments.",
|
||||
"Headers.forEach requires 1 argument.",
|
||||
"Headers.forEach expects a function callback.",
|
||||
"Headers.prototype.get called on incompatible receiver undefined.",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
* - fetch/api/headers/{headers-basic,headers-casing,headers-combine,headers-errors,headers-normalize,header-setcookie}.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
|
||||
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
|
||||
* arrays), or a custom iterator on a Headers instance are left out, as are two set-cookie cases that Bun's own Headers
|
||||
* fails by sorting `set-cookie2` ahead of `set-cookie`.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -166,3 +170,411 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
|
||||
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
|
||||
const testharness = `
|
||||
const failures = []
|
||||
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
|
||||
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
|
||||
function assert_true(actual, message) { assert_equals(actual, true, message) }
|
||||
function assert_false(actual, message) { assert_equals(actual, false, message) }
|
||||
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
|
||||
function assert_nested_array_equals(actual, expected) { assert_array_equals(actual, expected) }
|
||||
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
|
||||
function assert_unreached() { throw new Error("unreachable") }
|
||||
`
|
||||
|
||||
describe("Headers WPT parity (fetch/api/headers)", () => {
|
||||
test("headers-basic.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { new Headers() }, "Create headers from no parameter")
|
||||
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
|
||||
test(function() { new Headers({}) }, "Create headers from empty object")
|
||||
var parameters = [null, 1]
|
||||
parameters.forEach(function(parameter) {
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
|
||||
})
|
||||
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
|
||||
var headerSeq = []
|
||||
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeq)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
|
||||
}, "Create headers with sequence")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with record")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
var headers2 = new Headers(headers)
|
||||
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with existing headers")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.append(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check append method")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.set(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check set method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
|
||||
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
|
||||
}, "Check has method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) {
|
||||
assert_true(headers.has(name), "headers have a header: " + name)
|
||||
headers.delete(name)
|
||||
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
|
||||
}
|
||||
}, "Check delete method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
|
||||
}, "Check get method")
|
||||
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
|
||||
var sortedHeaderDict = {}
|
||||
var headerValues = []
|
||||
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
|
||||
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
|
||||
headerValues.push(headerEntriesDict[value])
|
||||
return value.toLowerCase()
|
||||
}).sort()
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.keys(), sortedHeaderKeys)
|
||||
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
|
||||
}, "Check keys method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
|
||||
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
|
||||
}, "Check values method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
|
||||
}, "Check entries method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
}, "Check Symbol.iterator method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var index = 0
|
||||
headers.forEach(function(value, key, container) {
|
||||
assert_equals(headers, container)
|
||||
assert_equals(key, sortedHeaderKeys[index])
|
||||
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
|
||||
index++
|
||||
})
|
||||
assert_equals(index, sortedHeaderKeys.length)
|
||||
}, "Check forEach method")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
headers.delete("foo")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz"])
|
||||
assert_array_equals(actualValues, ["0", "1"])
|
||||
}, "Iteration skips elements removed while iterating")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.delete("bar")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "3"])
|
||||
}, "Removing elements already iterated over causes an element to be skipped during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("X-yZ", "4")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
|
||||
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
|
||||
}, "Appending a value pair during iteration causes it to be reached during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("abc", "-1")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
|
||||
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-casing.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
var headerDictCase = {"UPPERCASE": "value1", "lowercase": "value2", "mixedCase": "value3", "Content-TYPE": "value4"}
|
||||
function checkHeadersCase(originalName, headersToCheck, expectedDict) {
|
||||
var lowCaseName = originalName.toLowerCase()
|
||||
var upCaseName = originalName.toUpperCase()
|
||||
var expectedValue = expectedDict[originalName]
|
||||
assert_equals(headersToCheck.get(originalName), expectedValue, "name: " + originalName + " has value: " + expectedValue)
|
||||
assert_equals(headersToCheck.get(lowCaseName), expectedValue, "name: " + lowCaseName + " has value: " + expectedValue)
|
||||
assert_equals(headersToCheck.get(upCaseName), expectedValue, "name: " + upCaseName + " has value: " + expectedValue)
|
||||
}
|
||||
test(function() {
|
||||
var headers = new Headers(headerDictCase)
|
||||
for (const name in headerDictCase) checkHeadersCase(name, headers, headerDictCase)
|
||||
}, "Create headers, names use characters with different case")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (const name in headerDictCase) {
|
||||
headers.append(name, headerDictCase[name])
|
||||
checkHeadersCase(name, headers, headerDictCase)
|
||||
}
|
||||
}, "Check append method, names use characters with different case")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (const name in headerDictCase) {
|
||||
headers.set(name, headerDictCase[name])
|
||||
checkHeadersCase(name, headers, headerDictCase)
|
||||
}
|
||||
}, "Check set method, names use characters with different case")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (const name in headerDictCase) headers.set(name, headerDictCase[name])
|
||||
for (const name in headerDictCase) headers.delete(name.toLowerCase())
|
||||
for (const name in headerDictCase) assert_false(headers.has(name), "header " + name + " should have been deleted")
|
||||
}, "Check delete method, names use characters with different case")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-combine.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
var headerSeqCombine = [["single", "singleValue"], ["double", "doubleValue1"], ["double", "doubleValue2"], ["triple", "tripleValue1"], ["triple", "tripleValue2"], ["triple", "tripleValue3"]]
|
||||
var expectedDict = {"single": "singleValue", "double": "doubleValue1, doubleValue2", "triple": "tripleValue1, tripleValue2, tripleValue3"}
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeqCombine)
|
||||
for (const name in expectedDict) assert_equals(headers.get(name), expectedDict[name])
|
||||
}, "Create headers using same name for different values")
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeqCombine)
|
||||
for (const name in expectedDict) {
|
||||
assert_true(headers.has(name), "name: " + name + " has value(s)")
|
||||
headers.delete(name)
|
||||
assert_false(headers.has(name), "name: " + name + " has no value(s) anymore")
|
||||
}
|
||||
}, "Check delete and has methods when using same name for different values")
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeqCombine)
|
||||
for (const name in expectedDict) {
|
||||
headers.set(name, "newSingleValue")
|
||||
assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue")
|
||||
}
|
||||
}, "Check set methods when called with already used name")
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeqCombine)
|
||||
for (const name in expectedDict) {
|
||||
var value = headers.get(name)
|
||||
headers.append(name, "newSingleValue")
|
||||
assert_equals(headers.get(name), (value + ", " + "newSingleValue"))
|
||||
}
|
||||
}, "Check append methods when called with already used name")
|
||||
test(() => {
|
||||
const headers = new Headers([["1", "a"], ["1", "b"]])
|
||||
for (let header of headers) assert_array_equals(header, ["1", "a, b"])
|
||||
}, "Iterate combined values")
|
||||
test(() => {
|
||||
const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), expected = [["1", "b"], ["2", "a, b"]]
|
||||
let i = 0
|
||||
for (let header of headers) {
|
||||
assert_array_equals(header, expected[i])
|
||||
i++
|
||||
}
|
||||
assert_equals(i, 2)
|
||||
}, "Iterate combined values in sorted order")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-errors.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
|
||||
var badNames = ["invalid\u0100", {}]
|
||||
var badValues = ["invalid\u0100"]
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
|
||||
})
|
||||
test(function() {
|
||||
var headers = new Headers([["name", "value"]])
|
||||
assert_throws_js(TypeError, function() { headers.forEach() })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(1) })
|
||||
}, "Headers forEach throws if argument is not callable")
|
||||
test(function() {
|
||||
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
|
||||
var counter = 0
|
||||
try {
|
||||
headers.forEach(function(value, name) {
|
||||
counter++
|
||||
if (name == "name2") throw "error"
|
||||
})
|
||||
} catch (e) {
|
||||
assert_equals(counter, 2)
|
||||
assert_equals(e, "error")
|
||||
return
|
||||
}
|
||||
assert_unreached()
|
||||
}, "Headers forEach loop should stop if callback is throwing exception")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-normalize.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
const expectations = {
|
||||
"name1": [" space ", "space"],
|
||||
"name2": ["\\ttab\\t", "tab"],
|
||||
"name3": [" spaceAndTab\\t", "spaceAndTab"],
|
||||
"name4": ["\\r\\n newLine", "newLine"],
|
||||
"name5": ["newLine\\r\\n ", "newLine"],
|
||||
"name6": ["\\r\\n\\tnewLine", "newLine"],
|
||||
"name7": ["\\t\\f\\tnewLine\\n", "\\f\\tnewLine"],
|
||||
"name8": ["newLine\\xa0", "newLine\\xa0"],
|
||||
}
|
||||
test(function () {
|
||||
const headerDict = Object.fromEntries(Object.entries(expectations).map(([name, [actual]]) => [name, actual]))
|
||||
var headers = new Headers(headerDict)
|
||||
for (const name in expectations) {
|
||||
const expected = expectations[name][1]
|
||||
assert_equals(headers.get(name), expected, "name: " + name + " has normalized value: " + expected)
|
||||
}
|
||||
}, "Create headers with not normalized values")
|
||||
test(function () {
|
||||
var headers = new Headers()
|
||||
for (const name in expectations) {
|
||||
headers.append(name, expectations[name][0])
|
||||
const expected = expectations[name][1]
|
||||
assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected)
|
||||
}
|
||||
}, "Check append method with not normalized values")
|
||||
test(function () {
|
||||
var headers = new Headers()
|
||||
for (const name in expectations) {
|
||||
headers.set(name, expectations[name][0])
|
||||
const expected = expectations[name][1]
|
||||
assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected)
|
||||
}
|
||||
}, "Check set method with not normalized values")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("header-setcookie.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
const headerList = [["set-cookie", "foo=bar"], ["Set-Cookie", "fizz=buzz; domain=example.com"]]
|
||||
const setCookie2HeaderList = [["set-cookie2", "foo2=bar2"], ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"]]
|
||||
test(function () {
|
||||
const headers = new Headers(headerList)
|
||||
assert_equals(headers.get("set-cookie"), "foo=bar, fizz=buzz; domain=example.com")
|
||||
}, "Headers.prototype.get combines set-cookie headers in order")
|
||||
test(function () {
|
||||
const headers = new Headers(headerList)
|
||||
assert_nested_array_equals([...headers], [["set-cookie", "foo=bar"], ["set-cookie", "fizz=buzz; domain=example.com"]])
|
||||
}, "Headers iterator does not combine set-cookie headers")
|
||||
test(function () {
|
||||
const headers = new Headers(setCookie2HeaderList)
|
||||
assert_nested_array_equals([...headers], [["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"]])
|
||||
}, "Headers iterator does not special case set-cookie2 headers")
|
||||
test(function () {
|
||||
const headers = new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]])
|
||||
assert_nested_array_equals([...headers], [["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]])
|
||||
}, "Headers iterator preserves set-cookie ordering")
|
||||
test(function () {
|
||||
const headers = new Headers(headerList)
|
||||
assert_true(headers.has("sEt-cOoKiE"))
|
||||
}, "Headers.prototype.has works for set-cookie")
|
||||
test(function () {
|
||||
const headers = new Headers(headerList)
|
||||
headers.set("set-cookie", "foo2=bar2")
|
||||
assert_nested_array_equals([...headers], [["set-cookie", "foo2=bar2"]])
|
||||
}, "Headers.prototype.set works for set-cookie")
|
||||
test(function () {
|
||||
const headers = new Headers(headerList)
|
||||
headers.delete("set-Cookie")
|
||||
assert_nested_array_equals([...headers], [])
|
||||
}, "Headers.prototype.delete works for set-cookie")
|
||||
test(function () { assert_array_equals(new Headers().getSetCookie(), []) }, "Headers.prototype.getSetCookie with no headers present")
|
||||
test(function () { assert_array_equals(new Headers([headerList[0]]).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header")
|
||||
test(function () { assert_array_equals(new Headers({ "Set-Cookie": "foo=bar" }).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header created from an object")
|
||||
test(function () { assert_array_equals(new Headers(headerList).getSetCookie(), ["foo=bar", "fizz=buzz; domain=example.com"]) }, "Headers.prototype.getSetCookie with multiple headers")
|
||||
test(function () { assert_array_equals(new Headers([["set-cookie", ""]]).getSetCookie(), [""]) }, "Headers.prototype.getSetCookie with an empty header")
|
||||
test(function () { assert_array_equals(new Headers([["set-cookie", "x"], ["set-cookie", "x"]]).getSetCookie(), ["x", "x"]) }, "Headers.prototype.getSetCookie with two equal headers")
|
||||
test(function () { assert_array_equals(new Headers([["set-cookie2", "x"], ["set-cookie", "y"], ["set-cookie2", "z"]]).getSetCookie(), ["y"]) }, "Headers.prototype.getSetCookie ignores set-cookie2 headers")
|
||||
test(function () { assert_array_equals(new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]).getSetCookie(), ["z=z", "a=a", "n=n"]) }, "Headers.prototype.getSetCookie preserves header ordering")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { CodeModeWeb } from "./web.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -59,8 +60,8 @@ export type Inventory = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
@@ -82,9 +83,9 @@ export const create = (
|
||||
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
lock.withPermit(
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
inventory,
|
||||
@@ -103,27 +104,7 @@ export const create = (
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return text === "" ? null : text
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
})
|
||||
},
|
||||
onToolCallEnd: ({ index, name, input, outcome }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = {
|
||||
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
|
||||
status: outcome === "success" ? "completed" : "error",
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
progressHooks(record),
|
||||
).execute(code)
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
@@ -158,6 +139,38 @@ export const create = (
|
||||
} satisfies Info
|
||||
}
|
||||
|
||||
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
|
||||
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
|
||||
const rows = new WeakMap<object, number>()
|
||||
const start = (call: object, entry: ExecuteCall) =>
|
||||
record((items) => {
|
||||
rows.set(call, items.length)
|
||||
return [...items, entry]
|
||||
})
|
||||
const settle = (call: object, result: CodeMode.CallResult) =>
|
||||
record((items) => {
|
||||
const index = rows.get(call)
|
||||
if (index === undefined) return items
|
||||
const next = [...items]
|
||||
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
|
||||
return next
|
||||
})
|
||||
return {
|
||||
"tool.before": (call) => {
|
||||
const shown = displayInput(call.input)
|
||||
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
|
||||
},
|
||||
"tool.after": settle,
|
||||
"extension.before": (call) =>
|
||||
start(call, {
|
||||
tool: call.name,
|
||||
status: "running",
|
||||
...(call.name === "fetch" ? { input: CodeModeWeb.display(call.args) } : {}),
|
||||
}),
|
||||
"extension.after": settle,
|
||||
} satisfies CodeMode.Hooks
|
||||
}
|
||||
|
||||
export const catalog = (inventory: Inventory) => {
|
||||
const pinned = new Set(
|
||||
Array.from(inventory.tools.values())
|
||||
@@ -204,7 +217,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
|
||||
function runtime(
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
hooks?: CodeMode.Hooks,
|
||||
) {
|
||||
// A path may carry namespace metadata, a callable tool, child tools, or all three.
|
||||
const root: ToolNode = { children: new Map() }
|
||||
@@ -219,7 +232,7 @@ function runtime(
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export * as CodeModeWeb from "./web.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
type Init = {
|
||||
readonly method?: string
|
||||
readonly headers?: Record<string, string> | Array<[string, string]>
|
||||
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
|
||||
}
|
||||
|
||||
const fetch = async (input: string | URL, init: Init = {}) => {
|
||||
const response = await globalThis.fetch(input, {
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
const bytes = await response.bytes()
|
||||
const headers = Object.fromEntries(response.headers)
|
||||
const text = () => new TextDecoder().decode(bytes)
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
redirected: response.redirected,
|
||||
headers: {
|
||||
get: (name: string) => headers[name.toLowerCase()] ?? null,
|
||||
has: (name: string) => name.toLowerCase() in headers,
|
||||
entries: () => Object.entries(headers),
|
||||
},
|
||||
text: async () => text(),
|
||||
json: async () => JSON.parse(text()) as unknown,
|
||||
bytes: async () => bytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const extension = Extension.make({ name: "web", globals: { fetch } })
|
||||
|
||||
/** What to show for a fetch call: its method and URL. */
|
||||
export const display = (args: ReadonlyArray<unknown>) => {
|
||||
const [input, init] = args as [string | URL, Init | undefined]
|
||||
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
|
||||
}
|
||||
@@ -168,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
if (!resource) return identity
|
||||
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
|
||||
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
|
||||
return new URL(resource)
|
||||
const canonical = new URL(resource)
|
||||
// The transport dials the configured URL with extra query parameters and some servers echo
|
||||
// that back as the resource. Query is transport detail, not identity: the token stays bound
|
||||
// to the configured URL so a later refresh names the same resource the login did.
|
||||
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
|
||||
return canonical
|
||||
},
|
||||
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
|
||||
...(redirect ? { state: () => redirect.state } : {}),
|
||||
|
||||
+76
-57
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
|
||||
|
||||
type Data = {
|
||||
models: Map<Provider.ID, Map<ID, MutableInfo>>
|
||||
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
defaultModel?: { providerID: Provider.ID; modelID: ID }
|
||||
}
|
||||
|
||||
@@ -82,59 +82,73 @@ const layer = Layer.effect(
|
||||
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
|
||||
name: "model",
|
||||
initial: () => ({
|
||||
models: new Map(
|
||||
(input?.available ?? []).map((record) => [
|
||||
record.provider.id,
|
||||
new Map(
|
||||
Array.from(record.models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...structuredClone(model),
|
||||
id,
|
||||
providerID: record.provider.id,
|
||||
} as MutableInfo,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
|
||||
}),
|
||||
editor: (data) => ({
|
||||
list: (providerID) =>
|
||||
providerID === undefined
|
||||
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
|
||||
: Array.from(data.models.get(providerID)?.values() ?? []),
|
||||
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = data.models.get(providerID)
|
||||
if (!models) return
|
||||
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
data.models.get(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
editor: (data) => {
|
||||
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
|
||||
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
|
||||
const drafts = new WeakSet<Info>()
|
||||
const writable = (providerID: Provider.ID) => {
|
||||
const current = data.models.get(providerID)
|
||||
if (!current) return undefined
|
||||
if (owned.has(current)) return current as Map<ID, Info>
|
||||
const copy = new Map(current)
|
||||
owned.add(copy)
|
||||
data.models.set(providerID, copy)
|
||||
return copy
|
||||
}
|
||||
const draft = (providerID: Provider.ID, modelID: ID) => {
|
||||
const models = writable(providerID)
|
||||
if (!models) return undefined
|
||||
const current = models.get(modelID)
|
||||
if (!current) return undefined
|
||||
if (drafts.has(current)) return current as MutableInfo
|
||||
const copy = structuredClone(current) as MutableInfo
|
||||
drafts.add(copy)
|
||||
models.set(modelID, copy)
|
||||
return copy
|
||||
}
|
||||
return {
|
||||
list: (providerID) => {
|
||||
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
|
||||
return ids.flatMap((id) =>
|
||||
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
|
||||
)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}),
|
||||
get: draft,
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = writable(providerID)
|
||||
if (!models) return
|
||||
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
drafts.add(model)
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
writable(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}
|
||||
},
|
||||
// read() also refreshes dependencies changed inside a State.batch before notification.
|
||||
notify: () => notify,
|
||||
})
|
||||
@@ -147,6 +161,8 @@ const layer = Layer.effect(
|
||||
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
}
|
||||
| undefined
|
||||
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
|
||||
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
|
||||
const read = Effect.fn("Model.snapshot")(function* () {
|
||||
while (true) {
|
||||
const current = yield* providers.snapshot()
|
||||
@@ -165,9 +181,10 @@ const layer = Layer.effect(
|
||||
return [
|
||||
providerID,
|
||||
new Map(
|
||||
Array.from(models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
Array.from(models, ([id, model]) => {
|
||||
const reusable = merged.get(model)
|
||||
if (reusable && reusable.provider === provider) return [id, reusable.model]
|
||||
const value = {
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
@@ -176,8 +193,10 @@ const layer = Layer.effect(
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info,
|
||||
]),
|
||||
} satisfies Info
|
||||
merged.set(model, { provider, model: value })
|
||||
return [id, value]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
|
||||
@@ -6,12 +6,12 @@ import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function identity(model: { readonly name: string; readonly ref: Model.Ref }) {
|
||||
export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) {
|
||||
return [
|
||||
"# Your Model",
|
||||
`- Provider: ${model.provider}`,
|
||||
`- Name: ${model.name}`,
|
||||
`- Provider ID: ${model.ref.providerID}`,
|
||||
`- Model ID: ${model.ref.id}`,
|
||||
`- ID: ${model.ref.providerID}/${model.ref.id}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ export const Plugin = define({
|
||||
(yield* ctx.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
) ?? Model.Info.default(event.model.providerID, event.model.id)
|
||||
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
|
||||
const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID)
|
||||
event.system.splice(
|
||||
1,
|
||||
0,
|
||||
SystemPart.make(
|
||||
identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
|
||||
@@ -228,12 +228,18 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
|
||||
|
||||
// Every location references the same index for a shared immutable definition array.
|
||||
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
|
||||
function index(models: readonly Model.Info[]) {
|
||||
const cached = definitions.get(models)
|
||||
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
|
||||
function index(providerID: ID, models: readonly Model.Info[]) {
|
||||
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
|
||||
const cached = indexes.get(providerID)
|
||||
if (cached) return cached
|
||||
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
|
||||
definitions.set(models, result)
|
||||
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
|
||||
const result = freeze(
|
||||
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
|
||||
true,
|
||||
)
|
||||
indexes.set(providerID, result)
|
||||
definitions.set(models, indexes)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -281,7 +287,7 @@ const layer = Layer.effect(
|
||||
add: (definition) => {
|
||||
records.set(definition.info.id, {
|
||||
provider: structuredClone(definition.info) as MutableInfo,
|
||||
models: index(definition.models),
|
||||
models: index(definition.info.id, definition.models),
|
||||
sourceConnection: definition.sourceConnection,
|
||||
})
|
||||
},
|
||||
@@ -300,7 +306,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
models: {
|
||||
set: (id, values) => {
|
||||
entry(id).models = index(values)
|
||||
entry(id).models = index(id, values)
|
||||
},
|
||||
update: (providerID, modelID, update) => {
|
||||
const record = entry(providerID)
|
||||
@@ -338,7 +344,7 @@ const layer = Layer.effect(
|
||||
// Registrations may outlive a borrowed service layer; their later disposal must
|
||||
// not query dependencies that have already closed.
|
||||
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
|
||||
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
|
||||
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
|
||||
const snapshot = Effect.fn("Provider.snapshot")(function* () {
|
||||
while (true) {
|
||||
const revision = integrations.revision()
|
||||
@@ -347,10 +353,6 @@ const layer = Layer.effect(
|
||||
const connections = yield* integrations.list()
|
||||
// Either fold can disable a plugin that also contributed to the other domain.
|
||||
if (revision !== integrations.revision() || records !== state.get()) continue
|
||||
const access = JSON.stringify(
|
||||
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
|
||||
)
|
||||
if (cached?.records === records && cached.access === access) return cached.value
|
||||
const byID = new Map(connections.map((integration) => [integration.id, integration]))
|
||||
const available = Array.from(records.values()).filter((record) => {
|
||||
if (record.provider.activation === "disabled") return false
|
||||
@@ -366,8 +368,15 @@ const layer = Layer.effect(
|
||||
if (integration?.connections.length) return true
|
||||
return record.provider.integrationID === undefined && !integration
|
||||
})
|
||||
// A credential change that leaves the same definitions available is not a catalog change.
|
||||
if (
|
||||
cached?.records === records &&
|
||||
cached.value.available.length === available.length &&
|
||||
cached.value.available.every((record, index) => record === available[index])
|
||||
)
|
||||
return cached.value
|
||||
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
|
||||
cached = { records, access, value }
|
||||
cached = { records, value }
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -154,6 +154,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
let catalog: { data: Data; names: string; value: CodeModeCatalog.Inventory } | undefined
|
||||
const state = State.create<Data, Editor>({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
@@ -201,8 +202,9 @@ const layer = Layer.effect(
|
||||
editor.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
notify: (value) =>
|
||||
Effect.forEach(
|
||||
notify: (value) => {
|
||||
catalog = undefined
|
||||
return Effect.forEach(
|
||||
value.errors,
|
||||
({ kind, name, namespace, error }) =>
|
||||
Effect.logError(`Skipping invalid ${kind} registration`, {
|
||||
@@ -211,7 +213,8 @@ const layer = Layer.effect(
|
||||
error: error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -219,15 +222,16 @@ const layer = Layer.effect(
|
||||
reload: state.reload,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const data = state.get()
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
for (const [name, tool] of data.tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const namespaces = state.get().namespaces
|
||||
const namespaces = data.namespaces
|
||||
const codeModeInventory = { tools: codeModeTools, namespaces }
|
||||
const codeModeEnabled = !whollyDisabled("execute", rules)
|
||||
const codeModeTool = codeModeEnabled
|
||||
@@ -237,7 +241,15 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
|
||||
const names = Array.from(codeModeTools.keys()).join("\0")
|
||||
// Discovery is immutable for a registry revision and visible tool set. Keep request
|
||||
// definitions/executors fresh, but share the much larger rendered catalog across steps.
|
||||
const codeModeCatalog = !codeModeEnabled
|
||||
? undefined
|
||||
: catalog?.data === data && catalog.names === names
|
||||
? catalog.value
|
||||
: CodeModeTool.catalog(codeModeInventory)
|
||||
if (codeModeCatalog) catalog = { data, names, value: codeModeCatalog }
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as OpenCodeTools from "./opencode.js"
|
||||
import { SystemPart, ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -24,47 +23,6 @@ export const MoveInput = Schema.Struct({
|
||||
|
||||
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
|
||||
|
||||
export const ModelsInput = Schema.Struct({
|
||||
query: Schema.optionalKey(Schema.String).annotate({
|
||||
description: "Text to search for in model names and IDs.",
|
||||
}),
|
||||
provider: Schema.optionalKey(Schema.String).annotate({
|
||||
description: "Provider ID or name to filter by. Try your own provider first.",
|
||||
}),
|
||||
all: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description: "Include older versions of each model family. By default only the newest version is listed.",
|
||||
}),
|
||||
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({
|
||||
description: "Maximum number of models to return. Defaults to 20.",
|
||||
}),
|
||||
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
|
||||
description: "Number of models to skip, for paging through results.",
|
||||
}),
|
||||
})
|
||||
|
||||
const ModelEntry = Schema.Struct({
|
||||
id: Schema.String.annotate({ description: "providerID/modelID" }),
|
||||
name: Schema.String,
|
||||
released: Model.Info.fields.time.fields.released.annotate({
|
||||
description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.",
|
||||
}),
|
||||
variants: Schema.Array(Model.VariantID),
|
||||
cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }),
|
||||
status: Model.Info.fields.status,
|
||||
})
|
||||
|
||||
const ModelsOutput = Schema.Struct({
|
||||
providers: Schema.Array(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }),
|
||||
}),
|
||||
).annotate({ description: "Matching models grouped by provider." }),
|
||||
total: Schema.Int.annotate({ description: "Number of matching models across all pages." }),
|
||||
next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }),
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
@@ -127,67 +85,6 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
})
|
||||
draft.add({
|
||||
name: "models",
|
||||
description:
|
||||
"Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.",
|
||||
input: ModelsInput,
|
||||
output: ModelsOutput,
|
||||
options: { namespace: "opencode", codemode: true },
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const offset = input.offset ?? 0
|
||||
const limit = input.limit ?? 20
|
||||
const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? []
|
||||
const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name]))
|
||||
const provider = input.provider?.toLowerCase()
|
||||
const matching = (yield* ctx.model.list()).data
|
||||
.filter(
|
||||
(model) =>
|
||||
provider === undefined ||
|
||||
model.providerID.toLowerCase() === provider ||
|
||||
names.get(model.providerID)?.toLowerCase() === provider,
|
||||
)
|
||||
.filter((model) => {
|
||||
const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase()
|
||||
return terms.every((term) => text.includes(term))
|
||||
})
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.providerID.localeCompare(right.providerID) || right.time.released - left.time.released,
|
||||
)
|
||||
.filter((model, index, sorted) => {
|
||||
if (input.all || model.family === undefined) return true
|
||||
return (
|
||||
sorted.findIndex(
|
||||
(other) => other.providerID === model.providerID && other.family === model.family,
|
||||
) === index
|
||||
)
|
||||
})
|
||||
const page = matching.slice(offset, offset + limit)
|
||||
const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({
|
||||
id,
|
||||
name: names.get(id) ?? id,
|
||||
models: page
|
||||
.filter((model) => model.providerID === id)
|
||||
.map((model) => ({
|
||||
id: `${model.providerID}/${model.id}`,
|
||||
name: model.name,
|
||||
released: model.time.released,
|
||||
variants: model.variants.map((variant) => variant.id),
|
||||
cost: model.cost,
|
||||
status: model.status,
|
||||
})),
|
||||
}))
|
||||
return {
|
||||
output: {
|
||||
providers,
|
||||
total: matching.length,
|
||||
next: offset + limit < matching.length ? offset + limit : null,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))),
|
||||
})
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
@@ -27,16 +26,9 @@ const backgroundResult = (sessionID: SessionSchema.ID) => ({
|
||||
})
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({
|
||||
description:
|
||||
"The type of specialized agent to use for this task. If the user asks for a subagent by a name that is not one of the available subagents, they most likely mean a model: pick a suitable agent and pass the name through the model parameter instead.",
|
||||
}),
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
model: Schema.optionalKey(Schema.String).annotate({
|
||||
description:
|
||||
'NEVER set this unless the user explicitly asks for a particular model or variant. The value is written as "providerID/modelID", or "providerID/modelID#variant" to include a variant. Do not guess the ID: look the model up with the models tool, filtering to your own provider first.',
|
||||
}),
|
||||
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
|
||||
description:
|
||||
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
|
||||
@@ -69,34 +61,8 @@ export const Plugin = {
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const models = yield* Model.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
|
||||
const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
|
||||
const ref = yield* Effect.try({
|
||||
try: () => Model.Ref.parse(input),
|
||||
catch: () =>
|
||||
new ToolFailure({
|
||||
message: `Invalid model "${input}". Use "providerID/modelID" or "providerID/modelID#variant".`,
|
||||
}),
|
||||
})
|
||||
const model = (yield* models.available()).find(
|
||||
(model) => model.providerID === ref.providerID && model.id === ref.id,
|
||||
)
|
||||
if (model === undefined)
|
||||
return yield* new ToolFailure({
|
||||
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
|
||||
})
|
||||
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
model.variants.length === 0
|
||||
? `Model "${ref.providerID}/${ref.id}" has no variants. Omit the variant.`
|
||||
: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ")}.`,
|
||||
})
|
||||
return ref
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
editor.add({
|
||||
@@ -165,23 +131,24 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} is not a child of the current session`,
|
||||
})
|
||||
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
|
||||
// Continuing with a different agent switches the child, mirroring create semantics
|
||||
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
|
||||
if (existing !== undefined) {
|
||||
const switched = existing.agent !== agent.id
|
||||
const model = override ?? (switched ? agent.model : undefined)
|
||||
yield* Effect.all([
|
||||
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
|
||||
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
|
||||
]).pipe(
|
||||
// where the agent's configured model wins over the inherited one.
|
||||
if (existing !== undefined && existing.agent !== agent.id) {
|
||||
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
|
||||
Effect.andThen(
|
||||
agent.model === undefined
|
||||
? Effect.void
|
||||
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
|
||||
),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
|
||||
(error) =>
|
||||
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const model = override ?? agent.model ?? parent.model
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* sessions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
@@ -148,6 +148,156 @@ describe("Provider and Model", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
|
||||
})
|
||||
|
||||
it.effect("reuses the model catalog across credential switches", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const providerID = Provider.ID.make("switchable")
|
||||
const integrationID = Integration.ID.make(providerID)
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* providers.transform((editor) =>
|
||||
editor.add({
|
||||
info: Provider.Info.empty(providerID),
|
||||
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
|
||||
}),
|
||||
)
|
||||
expect(yield* models.available()).toEqual([])
|
||||
const log = yield* Ref.make<string[]>([])
|
||||
yield* bus.subscribe().pipe(
|
||||
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
const updates = Ref.get(log).pipe(
|
||||
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
|
||||
)
|
||||
|
||||
const first = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "first" }),
|
||||
})
|
||||
const materialized = yield* models.available()
|
||||
expect(materialized).toHaveLength(1)
|
||||
// Credential events reach Model on other fibers; let the connect land before switching.
|
||||
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
|
||||
|
||||
const second = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "second" }),
|
||||
})
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
yield* credentials.activate(first.id)
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
yield* credentials.remove(first.id)
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
|
||||
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
|
||||
// so once it has arrived the total shows whether any switch above published as well.
|
||||
yield* credentials.remove(second.id)
|
||||
expect(yield* models.available()).toEqual([])
|
||||
yield* settle(
|
||||
Ref.get(log).pipe(
|
||||
Effect.map(
|
||||
(types) =>
|
||||
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(yield* updates).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists direct edits to models returned by list and get", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("direct")
|
||||
const listed = Model.ID.make("listed")
|
||||
const fetched = Model.ID.make("fetched")
|
||||
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
|
||||
yield* providers.transform((editor) =>
|
||||
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
|
||||
)
|
||||
yield* models.transform((editor) => {
|
||||
editor.list(providerID).forEach((model) => {
|
||||
model.limit.context = 4096
|
||||
})
|
||||
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
|
||||
})
|
||||
|
||||
expect(yield* models.get(providerID, listed)).toMatchObject({
|
||||
limit: { context: 4096 },
|
||||
capabilities: { input: ["text", "image"] },
|
||||
})
|
||||
expect(yield* models.get(providerID, fetched)).toMatchObject({
|
||||
limit: { context: 4096 },
|
||||
capabilities: { input: ["text", "image", "pdf"] },
|
||||
})
|
||||
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("gives foreign definitions the registering provider's identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const source = Provider.ID.make("source")
|
||||
const mirror = Provider.ID.make("mirror")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const definitions = [Model.Info.default(source, modelID)]
|
||||
yield* providers.transform((editor) => {
|
||||
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
|
||||
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
|
||||
})
|
||||
|
||||
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
|
||||
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
|
||||
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps materialized models when another provider becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const existing = Provider.ID.make("existing")
|
||||
const added = Provider.ID.make("added")
|
||||
const edited = Model.ID.make("edited")
|
||||
const untouched = Model.ID.make("untouched")
|
||||
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
|
||||
yield* providers.transform((editor) => {
|
||||
editor.add({
|
||||
info: { ...Provider.Info.empty(existing), activation: "enabled" },
|
||||
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
|
||||
})
|
||||
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
|
||||
})
|
||||
yield* models.transform((editor) =>
|
||||
editor.update(existing, edited, (model) => {
|
||||
model.limit.context = 1
|
||||
}),
|
||||
)
|
||||
const before = required(yield* models.get(existing, untouched))
|
||||
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make(added),
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
})
|
||||
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
|
||||
added,
|
||||
existing,
|
||||
existing,
|
||||
])
|
||||
expect(yield* models.get(existing, untouched)).toBe(before)
|
||||
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from a provider's integration", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = Provider.ID.make("remote")
|
||||
@@ -511,3 +661,12 @@ describe("Provider and Model", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
|
||||
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
return yield* Effect.die("Timed out waiting for catalog events")
|
||||
})
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Name: gpt-4o-mini\\n- Provider ID: openai\\n- Model ID: gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Provider: openai\\n- Name: gpt-4o-mini\\n- ID: openai/gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -324,6 +324,44 @@ describe("MCP OAuth", () => {
|
||||
expect(probes.some((probe) => probe.includes("codemode"))).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps the configured URL as the resource when metadata echoes the dialed query", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server")
|
||||
return Response.json({
|
||||
issuer: url.origin,
|
||||
authorization_endpoint: `${url.origin}/authorize`,
|
||||
token_endpoint: `${url.origin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
})
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
|
||||
return Response.json({ resource: `${url.origin}/mcp${url.search}`, authorization_servers: [url.origin] })
|
||||
if (request.method === "POST" && url.pathname === "/token") {
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({ access_token: "next", token_type: "Bearer" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const url = `${server.url.origin}/mcp`
|
||||
const oauthProvider = await connectProvider(
|
||||
remote(url),
|
||||
memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]),
|
||||
)
|
||||
|
||||
// The transport dials with ?codemode=false and follows the 401 challenge to metadata that echoes it.
|
||||
await auth(oauthProvider, {
|
||||
serverUrl: `${url}?codemode=false`,
|
||||
resourceMetadataUrl: new URL(`${server.url.origin}/.well-known/oauth-protected-resource/mcp?codemode=false`),
|
||||
}).finally(() => server.stop(true))
|
||||
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenRequests[0]?.get("resource")).toBe(url)
|
||||
})
|
||||
|
||||
test("finds resource metadata through the 401 header when the well-known path is not served", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
|
||||
@@ -18,14 +18,15 @@ const it = testEffect(PluginTestLayer)
|
||||
test("formats the model identity part", () => {
|
||||
expect(
|
||||
IdentityPlugin.identity({
|
||||
provider: "OpenAI",
|
||||
name: "GPT-4o mini",
|
||||
ref: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-4o-mini") }),
|
||||
}),
|
||||
).toBe(["# Your Model", "- Name: GPT-4o mini", "- Provider ID: openai", "- Model ID: gpt-4o-mini"].join("\n"))
|
||||
).toBe(["# Your Model", "- Provider: OpenAI", "- Name: GPT-4o mini", "- ID: openai/gpt-4o-mini"].join("\n"))
|
||||
})
|
||||
|
||||
const identity = (name: string, id: string) =>
|
||||
["# Your Model", `- Name: ${name}`, "- Provider ID: test", `- Model ID: ${id}`].join("\n")
|
||||
const identity = (provider: string, name: string, id: string) =>
|
||||
["# Your Model", `- Provider: ${provider}`, `- Name: ${name}`, `- ID: test/${id}`].join("\n")
|
||||
|
||||
const context = (id: string): SessionHooks["context"] => ({
|
||||
sessionID: Session.ID.make("ses_model_identity"),
|
||||
@@ -44,6 +45,9 @@ it.effect("inserts the structured model block after the agent prompt", () =>
|
||||
const plugins = yield* Plugin.Service
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.make("test"), (provider) => {
|
||||
provider.name = "Test Provider"
|
||||
})
|
||||
editor.models.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
|
||||
model.name = "Muse Spark"
|
||||
})
|
||||
@@ -54,7 +58,7 @@ it.effect("inserts the structured model block after the agent prompt", () =>
|
||||
yield* hooks.trigger("session", "context", named)
|
||||
expect(named.system.map((part) => part.text)).toEqual([
|
||||
"Agent prompt",
|
||||
identity("Muse Spark", "meta/muse-spark-1.1"),
|
||||
identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"),
|
||||
"Initial context",
|
||||
])
|
||||
|
||||
@@ -62,7 +66,7 @@ it.effect("inserts the structured model block after the agent prompt", () =>
|
||||
yield* hooks.trigger("session", "context", fallback)
|
||||
expect(fallback.system.map((part) => part.text)).toEqual([
|
||||
"Agent prompt",
|
||||
identity("unknown-model", "unknown-model"),
|
||||
identity("Test Provider", "unknown-model", "unknown-model"),
|
||||
"Initial context",
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,12 @@ import { Document, Info } from "@opencode/schema/config"
|
||||
import { ConfigCompaction } from "@opencode/schema/config/compaction"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import type { Info as ToolInfo } from "@opencode/schema/tool"
|
||||
import { InstructionStateTable, SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionInboxTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode/core/session/sql"
|
||||
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
@@ -116,7 +121,7 @@ const testModel = (id: string, limit: ModelLimit = defaultModelLimit) => {
|
||||
const model = testModel("fake-model")
|
||||
const defaultSystem = SessionSystemPrompt.make([])
|
||||
const identity = (providerID: string, id: string) =>
|
||||
["# Your Model", `- Name: ${id}`, `- Provider ID: ${providerID}`, `- Model ID: ${id}`].join("\n")
|
||||
["# Your Model", `- Provider: ${providerID}`, `- Name: ${id}`, `- ID: ${providerID}/${id}`].join("\n")
|
||||
const fakeIdentity = identity("fake", "fake-model")
|
||||
const replacementIdentity = identity("fake", "replacement")
|
||||
const gptIdentity = identity("openai", "gpt-5")
|
||||
@@ -1735,11 +1740,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(TestLLM.text("Done", "text-build"))
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
"Build agent instructions",
|
||||
fakeIdentity,
|
||||
"Initial context",
|
||||
])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", fakeIdentity, "Initial context"])
|
||||
})
|
||||
|
||||
scenario("uses the configured default agent system for omitted-agent sessions", function* (s) {
|
||||
@@ -1760,11 +1761,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(TestLLM.text("Done", "text-reviewer"))
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
"Reviewer instructions",
|
||||
fakeIdentity,
|
||||
"Initial context",
|
||||
])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"])
|
||||
expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
|
||||
})
|
||||
|
||||
@@ -1787,11 +1784,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(TestLLM.text("Done", "text-selected"))
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
"Reviewer instructions",
|
||||
fakeIdentity,
|
||||
"Initial context",
|
||||
])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"])
|
||||
expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
|
||||
})
|
||||
|
||||
@@ -3033,11 +3026,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(resolutions).toBe(2)
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(s.requests[2]?.model).toBe(replacementModel)
|
||||
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
replacementIdentity,
|
||||
"Initial context",
|
||||
])
|
||||
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, replacementIdentity, "Initial context"])
|
||||
expect(systemTexts(s.requests[2])).toContain("Changed during compaction")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Overflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
|
||||
@@ -22,8 +22,8 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
|
||||
test("execute describes invariant Code Mode behavior", () => {
|
||||
expect(createCodeMode(new Map()).description).toBe(
|
||||
[
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const alpha = { id: "test/alpha", name: "Alpha", released: 300, variants: ["fast"], cost: [], status: "beta" }
|
||||
const beta = { id: "other/beta", name: "Beta", released: 200, variants: [], cost: [], status: "active" }
|
||||
const gamma = { id: "other/gamma", name: "Gamma Flash", released: 100, variants: [], cost: [], status: "active" }
|
||||
const gammaOld = {
|
||||
id: "other/gamma-old",
|
||||
name: "Gamma Flash Old",
|
||||
released: 50,
|
||||
variants: [],
|
||||
cost: [],
|
||||
status: "active",
|
||||
}
|
||||
|
||||
it.effect("groups available models by provider with paging", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.make("other"), (provider) => {
|
||||
provider.name = "Other Provider"
|
||||
})
|
||||
editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => {
|
||||
model.name = "Alpha"
|
||||
model.time.released = 300
|
||||
model.variants = [{ id: Model.VariantID.make("fast") }]
|
||||
model.status = "beta"
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => {
|
||||
model.name = "Beta"
|
||||
model.time.released = 200
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma"), (model) => {
|
||||
model.name = "Gamma Flash"
|
||||
model.time.released = 100
|
||||
model.family = Model.Family.make("gamma")
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma-old"), (model) => {
|
||||
model.name = "Gamma Flash Old"
|
||||
model.time.released = 50
|
||||
model.family = Model.Family.make("gamma")
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => {
|
||||
model.time.released = 400
|
||||
model.enabled = false
|
||||
})
|
||||
})
|
||||
yield* OpenCodeTools.Plugin.effect(pluginHost)
|
||||
const registry = yield* Tool.Service
|
||||
const run = (input: Record<string, unknown>) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_tool_opencode"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-${JSON.stringify(input)}`,
|
||||
name: "execute",
|
||||
input: { code: `return await tools.opencode.models(${JSON.stringify(input)})` },
|
||||
},
|
||||
}).pipe(Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")))
|
||||
|
||||
// Grouped by provider, newest first within each, disabled models excluded.
|
||||
expect(yield* run({})).toEqual({
|
||||
providers: [
|
||||
{ id: "other", name: "Other Provider", models: [beta, gamma] },
|
||||
{ id: "test", name: "test", models: [alpha] },
|
||||
],
|
||||
total: 3,
|
||||
next: null,
|
||||
})
|
||||
|
||||
// Paging slices the ordered list, so a page can end inside a provider group.
|
||||
expect(yield* run({ limit: 2 })).toEqual({
|
||||
providers: [{ id: "other", name: "Other Provider", models: [beta, gamma] }],
|
||||
total: 3,
|
||||
next: 2,
|
||||
})
|
||||
expect(yield* run({ limit: 2, offset: 2 })).toEqual({
|
||||
providers: [{ id: "test", name: "test", models: [alpha] }],
|
||||
total: 3,
|
||||
next: null,
|
||||
})
|
||||
|
||||
expect(yield* run({ provider: "other provider" })).toMatchObject({ total: 2, providers: [{ id: "other" }] })
|
||||
expect(yield* run({ provider: "test" })).toEqual({
|
||||
providers: [{ id: "test", name: "test", models: [alpha] }],
|
||||
total: 1,
|
||||
next: null,
|
||||
})
|
||||
|
||||
// Every word of the query must appear somewhere in the reference or display name, ignoring case.
|
||||
expect(yield* run({ query: "GAMMA" })).toEqual({
|
||||
providers: [{ id: "other", name: "Other Provider", models: [gamma] }],
|
||||
total: 1,
|
||||
next: null,
|
||||
})
|
||||
expect(yield* run({ query: "test/" })).toMatchObject({ total: 1, providers: [{ id: "test" }] })
|
||||
expect(yield* run({ query: "other flash" })).toMatchObject({ total: 1, providers: [{ models: [gamma] }] })
|
||||
expect(yield* run({ query: "gamma beta" })).toEqual({ providers: [], total: 0, next: null })
|
||||
|
||||
// Only the newest model of each family is listed unless `all` is set; the query is applied first.
|
||||
expect(yield* run({ all: true })).toMatchObject({
|
||||
total: 4,
|
||||
providers: [{ id: "other", models: [beta, gamma, gammaOld] }, { id: "test" }],
|
||||
})
|
||||
expect(yield* run({ query: "old" })).toMatchObject({ total: 1, providers: [{ models: [gammaOld] }] })
|
||||
expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null })
|
||||
}),
|
||||
)
|
||||
@@ -45,11 +45,6 @@ const completedOutput = (sessionID: Session.ID) =>
|
||||
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
const overrideModel = Model.Ref.make({
|
||||
id: Model.ID.make("override"),
|
||||
providerID: Provider.ID.make("test"),
|
||||
variant: Model.VariantID.make("fast"),
|
||||
})
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
const outputSessionID = (value: unknown) =>
|
||||
@@ -112,7 +107,7 @@ const executionNode = makeGlobalNode({
|
||||
const subagentPluginSupervisor = makeLocationNode({
|
||||
name: "test/subagent-plugins",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
|
||||
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
|
||||
deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node],
|
||||
})
|
||||
|
||||
const nodes = LayerNode.group([
|
||||
@@ -160,14 +155,6 @@ const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Provider.Service.use((providers) =>
|
||||
providers.transform((editor) => {
|
||||
editor.models.update(overrideModel.providerID, overrideModel.id, (model) => {
|
||||
model.variants = [{ id: Model.VariantID.make("fast") }]
|
||||
})
|
||||
editor.models.update(Provider.ID.make("test"), Model.ID.make("plain"), () => {})
|
||||
}),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((editor) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
@@ -628,63 +615,6 @@ describe("SubagentTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("runs the child on an explicitly requested model", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const call = (id: string, input: Record<string, unknown>) =>
|
||||
executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call" as const,
|
||||
id,
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this", ...input },
|
||||
},
|
||||
})
|
||||
|
||||
// The requested model beats the agent's configured model.
|
||||
const spawned = yield* call("call-override", { model: "test/override#fast" })
|
||||
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
|
||||
const child = yield* sessions.get(outputSessionID(spawned.metadata))
|
||||
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
|
||||
|
||||
// Continuing with a model switches the existing child even when the agent is unchanged.
|
||||
const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" })
|
||||
expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } })
|
||||
expect((yield* sessions.get(child.id)).model).toEqual({
|
||||
id: overrideModel.id,
|
||||
providerID: overrideModel.providerID,
|
||||
variant: Model.VariantID.make("default"),
|
||||
})
|
||||
|
||||
const failures = [
|
||||
["not-a-ref", 'Invalid model "not-a-ref". Use "providerID/modelID" or "providerID/modelID#variant".'],
|
||||
["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'],
|
||||
["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'],
|
||||
["test/plain#high", 'Model "test/plain" has no variants. Omit the variant.'],
|
||||
] as const
|
||||
for (const [model, message] of failures) {
|
||||
expect(yield* call(`call-${model}`, { model })).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns child runner failures as tool errors", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Vcs", () => {
|
||||
editor.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.base()).toBeNull()
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
@@ -146,10 +146,10 @@ describe("Vcs", () => {
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -188,7 +188,7 @@ describe("Vcs", () => {
|
||||
}),
|
||||
)
|
||||
expect(reads).toEqual(["final"])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -456,7 +456,7 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -466,7 +466,7 @@ describe("Vcs", () => {
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
@@ -502,7 +502,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
@@ -567,7 +567,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
@@ -575,14 +575,14 @@ describe("Vcs", () => {
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2965,7 +2965,7 @@ function ShellDisplay(props: {
|
||||
return stripAnsi(props.output?.trim() ?? "")
|
||||
})
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6 - (isRunning() ? 2 : 0)))
|
||||
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
|
||||
const input = createMemo(() => (props.command ? `${isRunning() ? "" : "$ "}${prefix()}${props.command}` : ""))
|
||||
const collapsed = createMemo(() => collapseShellOutput(input(), output(), maxLines, maxChars()))
|
||||
@@ -2991,8 +2991,30 @@ function ShellDisplay(props: {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
|
||||
<Spinner color={color()}>{limitedInput()}</Spinner>
|
||||
<Show
|
||||
when={isRunning()}
|
||||
fallback={
|
||||
<text
|
||||
fg={theme.text.default}
|
||||
wrapMode={expanded() ? "word" : "char"}
|
||||
maxHeight={expanded() ? undefined : 2}
|
||||
>
|
||||
{limitedInput()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Spinner color={color()} />
|
||||
<text
|
||||
fg={color()}
|
||||
wrapMode={expanded() ? "word" : "char"}
|
||||
maxHeight={expanded() ? undefined : 2}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
>
|
||||
{limitedInput()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={limitedOutput()}>
|
||||
<text fg={theme.text.subdued}>{limitedOutput()}</text>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Locale } from "./locale"
|
||||
import { stringWidth } from "./string-width"
|
||||
|
||||
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
|
||||
const lines = output.split("\n")
|
||||
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
|
||||
@@ -21,21 +24,60 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||
}
|
||||
|
||||
export function collapseShellOutput(input: string, output: string, maxLines: number, maxChars: number) {
|
||||
const content = [input, output].filter(Boolean).join("\n\n")
|
||||
const collapsed = collapseToolOutput(content, maxLines, maxChars)
|
||||
if (!collapsed.overflow) return { input, output, overflow: false }
|
||||
if (!input) return { input, output: collapseTail(output, maxLines, maxChars), overflow: true }
|
||||
if (!output) return { input: collapsed.output, output, overflow: true }
|
||||
if (!input) {
|
||||
const collapsed = collapseToolOutput(output, maxLines, maxChars)
|
||||
return {
|
||||
input,
|
||||
output: collapsed.overflow ? collapseTail(output, maxLines, maxChars) : output,
|
||||
overflow: collapsed.overflow,
|
||||
}
|
||||
}
|
||||
|
||||
const reserved = Math.max(1, Math.floor(maxChars / maxLines))
|
||||
const command = collapseToolOutput(
|
||||
input,
|
||||
Math.max(1, maxLines - 2),
|
||||
Math.max(1, maxChars - reserved - 2),
|
||||
).output
|
||||
const lines = Math.max(1, maxLines - command.split("\n").length - 1)
|
||||
const chars = Math.max(1, maxChars - Array.from(command).length - 2)
|
||||
return { input: command, output: collapseTail(output, lines, chars), overflow: true }
|
||||
const commandLines = Math.min(2, maxLines)
|
||||
const lineChars = Math.max(1, Math.floor(maxChars / Math.max(1, maxLines)))
|
||||
const command = collapseShellCommand(input, commandLines, lineChars)
|
||||
if (!output) return { input: command.output, output, overflow: command.overflow }
|
||||
|
||||
const lines = Math.max(1, maxLines - command.lines - 1)
|
||||
const chars = Math.max(1, maxChars - Array.from(command.output).length - 2)
|
||||
const collapsed = collapseToolOutput(output, lines, chars)
|
||||
return {
|
||||
input: command.output,
|
||||
output: collapsed.overflow ? collapseTail(output, lines, chars) : output,
|
||||
overflow: command.overflow || collapsed.overflow,
|
||||
}
|
||||
}
|
||||
|
||||
function collapseShellCommand(input: string, maxLines: number, lineWidth: number) {
|
||||
const visible: string[] = []
|
||||
let lines = 1
|
||||
let width = 0
|
||||
const overflow = Locale.graphemes(input).some((segment) => {
|
||||
if (segment === "\n") {
|
||||
if (lines >= maxLines) return true
|
||||
visible.push(segment)
|
||||
lines++
|
||||
width = 0
|
||||
return false
|
||||
}
|
||||
|
||||
const next = stringWidth(segment)
|
||||
if (width + next > lineWidth) {
|
||||
if (lines >= maxLines) return true
|
||||
lines++
|
||||
width = 0
|
||||
}
|
||||
visible.push(segment)
|
||||
width += next
|
||||
return false
|
||||
})
|
||||
if (!overflow) return { output: input, overflow, lines }
|
||||
|
||||
if (width >= lineWidth) {
|
||||
const removed = visible.pop()
|
||||
if (removed !== undefined && removed !== "\n") width -= stringWidth(removed)
|
||||
}
|
||||
return { output: visible.join("") + "…", overflow, lines }
|
||||
}
|
||||
|
||||
function collapseTail(output: string, maxLines: number, maxChars: number) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user