chore: generate

This commit is contained in:
opencode-agent[bot] 2026-07-03 05:20:28 +00:00
parent 2409c7a3d5
commit 3f0fc22b6e
14 changed files with 2091 additions and 1173 deletions

View file

@ -8,11 +8,11 @@ The package is currently private to this workspace. Its API is designed around t
```ts
// One execution
yield* CodeMode.execute({ tools, code })
yield * CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield* runtime.execute(code)
yield * runtime.execute(code)
// One agent-facing code tool
const codeTool = runtime.agentTool()
@ -55,7 +55,9 @@ const runtime = CodeMode.make({
},
})
const result = yield* runtime.execute(`
const result =
yield *
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
@ -72,8 +74,8 @@ Successful result values are JSON-safe data. A program that returns `undefined`,
```ts
const tool = Tool.make({
description,
input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice
input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice
run,
})
```
@ -89,13 +91,15 @@ The description and schemas are part of the model-visible tool contract. Keep de
Use `CodeMode.execute` for a single execution:
```ts
const result = yield* CodeMode.execute({
tools: { orders: { lookup: lookupOrder } },
code: `return await tools.orders.lookup({ id: "order_42" })`,
limits: { maxToolCalls: 10 },
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
})
const result =
yield *
CodeMode.execute({
tools: { orders: { lookup: lookupOrder } },
code: `return await tools.orders.lookup({ id: "order_42" })`,
limits: { maxToolCalls: 10 },
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
})
```
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
@ -110,10 +114,10 @@ const runtime = CodeMode.make({
limits: { timeoutMs: 30_000 },
})
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute }
runtime.agentTool() // { name, description, input, output, execute }
```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
@ -222,10 +226,10 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
The limits are exactly three knobs:
| Limit | Default | Bounds |
| --- | ---: | --- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| Limit | Default | Bounds |
| ---------------- | -------------------: | -------------------------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
@ -254,28 +258,25 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
Failures are data:
| Kind | Meaning |
| --- | --- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| Kind | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
```ts
import { toolError } from "@opencode-ai/codemode"
run: ({ id }) =>
authorized(id)
? loadOrder(id)
: Effect.fail(toolError("Order is unavailable"))
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.

View file

@ -39,6 +39,7 @@ package and was **deleted** in Wave 3 (done, see below).
From issue #34787 and design discussion. Do not relitigate these casually.
### Core direction
- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention;
the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention).
- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and
@ -52,6 +53,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
products/blog posts) in code, comments, commit messages, or docs in this repo.
### MCP / tools
- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary
`Tool.make(...)` definitions and hands CodeMode a plain tool tree.
- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask).
@ -61,8 +63,9 @@ From issue #34787 and design discussion. Do not relitigate these casually.
`tools.<server>.<tool>` namespaces before handing them over.
### Discovery / search
- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?,
limit? })` over the final tool tree, owned by this package.
limit? })` over the final tool tree, owned by this package.
- Search result item shape: `{ path, description, signature }` in an `{ items, total }`
wrapper. The `signature` string embeds the full input/output TypeScript types - in search
results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema
@ -82,6 +85,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
- Tools without an output schema render `unknown` as their return type.
### Schemas / Tool.make
- `Tool.make` carries rich metadata so search can render real signatures.
- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially
render-only - used for TypeScript rendering; the adapter may validate on its own). Leave
@ -90,6 +94,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
normalization for plugin authors can come later.
### Attachments / output
- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.)
- Tool calls return native structured payloads into the sandbox. Files/images emitted by
child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them
@ -100,6 +105,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
image bytes into context or drop attachments.
### Runtime behavior
- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` -
matching the original locked spec exactly. NO limit has a default (user direction, Fix 6
for the first two; extended to `maxOutputBytes` in the truncation-layering fix below):
@ -129,7 +135,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
- `console.*` is captured into `logs` on the result; the host appends them to model-facing
output. Not a tool call; costs no tool budget.
- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name,
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in
Wave 2).
@ -148,8 +154,9 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
`test/tool/registry.test.ts`).
### Wave 0 - scaffold (done)
- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool,
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`,
`effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only
touches `unstable/httpapi`, which this package doesn't use).
@ -157,6 +164,7 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
### Wave 1a - forgiving JS semantics (done)
Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance
spec. The seeded interpreter was deliberately strict; these behaviors replaced that:
@ -173,6 +181,7 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
null/undefined still throws (real JS throws too).
### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done)
`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
`codemode.ts` and `tool-runtime.ts` import without a cycle). Design:
@ -202,19 +211,20 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
interpolation renders `/regex/` and ISO dates directly.
### Wave 2 - API layer (done)
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this
wave; both packages typecheck clean.
- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect
Schema (validating, decoded both directions as before) OR a raw JSON Schema document
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
+ `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
cosmetic, fixed in Wave 4.
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
cosmetic, fixed in Wave 4.
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields,
instructions line, README section, seeded tests. AGENTS.md keeps a rephrased
@ -228,22 +238,23 @@ wave; both packages typecheck clean.
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
fire no end event (timeout kills the whole execution anyway).
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
later deleted that type and the internal limit system entirely.
- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
+ `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
truncation is now the only result-size mechanism.)
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
truncation is now the only result-size mechanism.)
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone
(`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged.
### Wave 3 - OpenCode MCP adapter (done)
`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package;
the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so
`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses
@ -257,7 +268,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search`
availability all come from this package and stay consistent with execution.
- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns:
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
Denials and host failures are mapped to `toolError(message)` so they surface as safe,
catchable in-program failures (MCP `isError` text propagates as `e.message`; without this
they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from
@ -270,10 +281,10 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
No handles, no `Result<T>` envelope, no base64 in the sandbox, no data-size tuning (the
`maxDataBytes` budget that existed at the time was deleted in Fix 5).
- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments
ride on both success and error results. Diagnostic `suggestions` not already contained in
the message are appended to error output. Native outer truncation stays on (adapter never
the message are appended to error output. Native outer truncation stays on (adapter never
sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time)
cut first - since the truncation-layering fix, native truncation is the only layer.
Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout);
@ -296,6 +307,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16).
### Wave 4 - instructions/prompting + polish (done)
Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a
real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both
packages typecheck clean.
@ -321,7 +333,7 @@ packages typecheck clean.
exported); `CodeMode.execute` (one-shot) passes it too, preserving the
`execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small
catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at
search. Search is *advertised* in the instructions only when the inlined list is PARTIAL,
search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL,
keeping small-catalog instructions tight.
- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures:
parse-string-results-as-JSON, return-small, console-for-intermediates, and
@ -340,7 +352,7 @@ packages typecheck clean.
- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`,
the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
`execute` tool registered alongside core tools (per-MCP registration suppressed; MCP
resource tools unaffected); the live description read back as "Available tools (PARTIAL -
56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace
@ -352,6 +364,7 @@ packages typecheck clean.
images, output truncation.
### Wave 5 - Promise generalization (done)
First-class promise values in the interpreter; the direct-tool-call-only `Promise.all`
restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new
in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode
@ -495,7 +508,7 @@ adapter needed **no changes**.
`tools.*`." (the second line drops the tools clause when the tree is empty).
- **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read
the `{ path, description, signature }` matches -> call by path -> `typeof res ===
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
COMPLETE the search/read steps collapse into "Pick a tool from the list under
`## Available tools`" and the steps renumber (4 instead of 5).
- **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw
@ -526,70 +539,72 @@ adapter needed **no changes**.
**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token
budget; namespaces must always be present):
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
the package stays dependency-free; keep in sync if the core heuristic changes.
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
+ stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
the package stays dependency-free; keep in sync if the core heuristic changes.
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
Fix 8). Namespace stub lines were and remain unbudgeted - every
namespace always appears with its tool count, even at budget 0 (asserted in package and
adapter tests).
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
configurable knobs; the internal limit system dies):
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
`InternalExecutionLimits` type is deleted.
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
`maxCollectionLength` (every array-length/object-field-count check - this knob was
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
`ExecuteResultSchema` (fine - the package is unreleased).
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
only because it produces a clearer error than a native stack-overflow RangeError; still
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
`(tools, maxToolCalls, hooks?, searchIndex?)`.
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
`TimeoutExceeded`, elapsed well under a few seconds).
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
behaviors unchanged.
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
now fail at the data boundary (`copyIn`) instead of at construction; array index
assignment allows any non-negative integer index (holes permitted, message now "must be
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
native stack during a walk still normalize to the existing "Execution exceeded the
maximum nesting depth." data diagnostic - failures remain data everywhere.
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
test - superseded by the package timeout regression test); rewrote the helpers that used
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
suites: 34 + 16.
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
`InternalExecutionLimits` type is deleted.
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
`maxCollectionLength` (every array-length/object-field-count check - this knob was
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
`ExecuteResultSchema` (fine - the package is unreleased).
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
only because it produces a clearer error than a native stack-overflow RangeError; still
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
`(tools, maxToolCalls, hooks?, searchIndex?)`.
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
`TimeoutExceeded`, elapsed well under a few seconds).
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
behaviors unchanged.
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
now fail at the data boundary (`copyIn`) instead of at construction; array index
assignment allows any non-negative integer index (holes permitted, message now "must be
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
native stack during a walk still normalize to the existing "Execution exceeded the
maximum nesting depth." data diagnostic - failures remain data everywhere.
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
test - superseded by the package timeout regression test); rewrote the helpers that used
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
suites: 34 + 16.
**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and
`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout /
@ -639,196 +654,200 @@ adapter suites: 34 + 16.
**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search**
(user direction: the fixed instruction prose was too verbose; two discovery fixes ride
along). All in `tool-runtime.ts`; no interpreter changes.
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
are replaced by four short lines (~188) built on "models already know JavaScript; name
only what is unusual or missing": (1) standard modern JS works - functions/closures,
destructuring, template literals, loops, try/catch, spread, optional chaining, the
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
stripped before execution, decorators are not supported; (3) NOT supported (each fails
with a message naming the alternative): classes, generators, for await...of,
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
before writing: probed empirically - classes/generators/for-await/.then/.catch/
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
return-small content now lives ONLY in the numbered Workflow steps (with their
compliance-driving justifications inline: "most tools return JSON as a string", "raw
payloads get truncated and waste context"); Rules keeps only bullets adding new
content - filter/aggregate collections in code, console.* intermediates (logs ride
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
step gained query-style guidance (`- short phrases like "list issues" work best`; a
clearly-a-query-string example, not a tool name), and the exact-path guidance is now
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
as-is rather than guessing segments".
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
absorbed the deduped parse/return-small justifications.
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
behavior (alphabetically-late namespaces starved to "none shown" while an early
namespace inlines everything) is replaced by round-robin fairness - in each round
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to
place its next-cheapest line against the shared token budget; a namespace whose next
line does not fit is done while the others keep going; stop when all are done. Every
namespace gets some representation before any namespace gets everything. Kept:
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
header, alphabetical namespace order in the output, cheapest-first within each
namespace's shown set.
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
be substring of indexed text), so query "issues" missed a tool whose text only says
"issue". Now each term expands to `termForms` - the term plus naive singular variants
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
the four field checks passes when ANY form matches. Weights, exact-path lookup, and
namespace scoping untouched. A true plural path match still outranks a singular-only
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
- **Tests**: package instruction/structure assertions updated to the new text; new
syntax-section test (leads with "Standard modern JavaScript works", names the
verified not-supported list, keeps the data-boundary note); the budget-exhaustion
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
plural/singular test (query "issues" finds a singular-only tool; ranking still
prefers the true "issues" path match). Adapter: description assertions updated; the
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
its inlined line) - it was "none shown" under starvation. README updated (budgeted
catalog paragraph -> round-robin; search paragraph -> singular variants;
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
0 fail; adapter suites: 34 + 16.
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
are replaced by four short lines (~188) built on "models already know JavaScript; name
only what is unusual or missing": (1) standard modern JS works - functions/closures,
destructuring, template literals, loops, try/catch, spread, optional chaining, the
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
stripped before execution, decorators are not supported; (3) NOT supported (each fails
with a message naming the alternative): classes, generators, for await...of,
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
before writing: probed empirically - classes/generators/for-await/.then/.catch/
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
return-small content now lives ONLY in the numbered Workflow steps (with their
compliance-driving justifications inline: "most tools return JSON as a string", "raw
payloads get truncated and waste context"); Rules keeps only bullets adding new
content - filter/aggregate collections in code, console.\* intermediates (logs ride
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
step gained query-style guidance (`- short phrases like "list issues" work best`; a
clearly-a-query-string example, not a tool name), and the exact-path guidance is now
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
as-is rather than guessing segments".
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
absorbed the deduped parse/return-small justifications.
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
behavior (alphabetically-late namespaces starved to "none shown" while an early
namespace inlines everything) is replaced by round-robin fairness - in each round
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to
place its next-cheapest line against the shared token budget; a namespace whose next
line does not fit is done while the others keep going; stop when all are done. Every
namespace gets some representation before any namespace gets everything. Kept:
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
header, alphabetical namespace order in the output, cheapest-first within each
namespace's shown set.
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
be substring of indexed text), so query "issues" missed a tool whose text only says
"issue". Now each term expands to `termForms` - the term plus naive singular variants
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
the four field checks passes when ANY form matches. Weights, exact-path lookup, and
namespace scoping untouched. A true plural path match still outranks a singular-only
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
- **Tests**: package instruction/structure assertions updated to the new text; new
syntax-section test (leads with "Standard modern JavaScript works", names the
verified not-supported list, keeps the data-boundary note); the budget-exhaustion
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
plural/singular test (query "issues" finds a singular-only tool; ranking still
prefers the true "issues" path match). Adapter: description assertions updated; the
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
its inlined line) - it was "none shown" under starvation. README updated (budgeted
catalog paragraph -> round-robin; search paragraph -> singular variants;
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
0 fail; adapter suites: 34 + 16.
**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
instructions and directed further cuts):
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
shape - verify what actually came back before relying on its fields." (Deliberately
does NOT suggest console.log - user review: naming it there nudges models to log AND
return the same data; the prompt stays console-neutral, neither for nor against.)
The media-stripping MECHANISM is unchanged and still tested; only the prose about it
is gone - the `[N images attached]` marker is self-explanatory in context.
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
as a next-iteration follow-up below.
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
shape - verify what actually came back before relying on its fields." (Deliberately
does NOT suggest console.log - user review: naming it there nudges models to log AND
return the same data; the prompt stays console-neutral, neither for nor against.)
The media-stripping MECHANISM is unchanged and still tested; only the prose about it
is gone - the `[N images attached]` marker is self-explanatory in context.
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
as a next-iteration follow-up below.
**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS
parity items, done as one focused pass; no public API or limit changes):
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
brand is lost on spread/boundary copies exactly like JS loses the prototype.
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
now include the engine's position detail in the message; safe - derived from the
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
keeps its own name when it is one of the standard seven. Tool failures and everything
without a specific analogue stay `"Error"` - internal class names never leak. Specific
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
The operator is handled in `evaluateBinaryExpression`
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
functions included); recognized rhs: the error constructors (a specific type matches its
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
catchable error naming the recognized constructors.
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
convention - for...of and spread work either way). The `retryableArrayMethods`
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
array properties still read `undefined`.
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
trimStart/trimEnd aliases.
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
expression:" prefix via `regexFailureReason`) and a shared escaping hint
(`escapeRegexHint`); flags failures list the valid flag letters; the
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
the single-match alternative.
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
reference as leaves** (contents not walked - Map/Set members are validated at their
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
considered and skipped to preserve the nudge). The HOST boundary (final result,
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
already preserved instances (reference copies, no checkpoint) - now tested.
- **Console formatting**: `formatConsoleArgument` is total and deep
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
literally - never the JSON `null`; finite numbers match their JSON form), nested
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
degrades to `...` - console can no longer fail a program. `console.table` guards with
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
walkers treat sandbox values as scalar cells.
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
Error`/splice mentions (nothing else reworded); README updated (checkpoint
preservation vs boundary serialization, error values/`instanceof`, new array/string
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
supported syntax, was already non-exhaustive, and stays accurate).
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
(34 + 16, green); both packages `tsgo --noEmit` clean.
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
brand is lost on spread/boundary copies exactly like JS loses the prototype.
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
now include the engine's position detail in the message; safe - derived from the
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
keeps its own name when it is one of the standard seven. Tool failures and everything
without a specific analogue stay `"Error"` - internal class names never leak. Specific
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
The operator is handled in `evaluateBinaryExpression`
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
functions included); recognized rhs: the error constructors (a specific type matches its
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
catchable error naming the recognized constructors.
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
convention - for...of and spread work either way). The `retryableArrayMethods`
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
array properties still read `undefined`.
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
trimStart/trimEnd aliases.
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
expression:" prefix via `regexFailureReason`) and a shared escaping hint
(`escapeRegexHint`); flags failures list the valid flag letters; the
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
the single-match alternative.
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
reference as leaves** (contents not walked - Map/Set members are validated at their
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
considered and skipped to preserve the nudge). The HOST boundary (final result,
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
already preserved instances (reference copies, no checkpoint) - now tested.
- **Console formatting**: `formatConsoleArgument` is total and deep
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
literally - never the JSON `null`; finite numbers match their JSON form), nested
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
degrades to `...` - console can no longer fail a program. `console.table` guards with
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
walkers treat sandbox values as scalar cells.
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
Error`/splice mentions (nothing else reworded); README updated (checkpoint
preservation vs boundary serialization, error values/`instanceof`, new array/string
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
supported syntax, was already non-exhaustive, and stays accurate).
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
(34 + 16, green); both packages `tsgo --noEmit` clean.
**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the
section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
`boundOutput` only runs when the host set the limit. Explicit values validate as before
(safe integer >= 0).
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
`execute` (its metadata never sets that key). One truncation layer, the host's - and it
is the richer one (file dump + explore/grep hint vs an inline marker).
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
that relied on the old default now asserts the oversized result reaches the shared
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
`boundOutput` only runs when the host set the limit. Explicit values validate as before
(safe integer >= 0).
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
`execute` (its metadata never sets that key). One truncation layer, the host's - and it
is the richer one (file dump + explore/grep hint vs an inline marker).
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
that relied on the old default now asserts the oversized result reaches the shared
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
@ -837,132 +856,137 @@ regular dependency; hosts depend on it themselves because the API surface is Eff
**Registry promotion + permission-aware catalog** (the "promote to a proper tool service"
restructure; fixes the section 4 permission-advertising bug):
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
append) is deleted; the early return that suppresses raw per-MCP registration when the
flag is on stays session-side, keyed on the same flag+tool-count condition.
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
tool count is consulted once (an Effect) before the synchronous filter, and code mode
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
- **Description split on the `describeTask` precedent**: the tool's static base
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
`catalogInstructions` in the adapter) at the same composition point as task - so
`plugin.trigger("tool.definition")` sees the base description first.
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
hides a tool; ask-level rules stay fully visible and prompt at call time) and
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
registry before building the catalog/search index; `execute` rebuilds the runtime per
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
even if the model guesses its name and yields the normal unknown-tool diagnostic.
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
at request-prep after descriptions are built and has no child-call equivalent.
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
through `Tool.Context` exactly like every registry tool).
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
cheap (grouping + string rendering); revisit only if profiling shows it matters.
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
`define(...)` construction; description assertions target `catalogInstructions`, the
registry's composition input) and gained permission coverage: deny excluded from
catalog/search, ask-level stays visible and callable, denied tool undispatchable
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
append) is deleted; the early return that suppresses raw per-MCP registration when the
flag is on stays session-side, keyed on the same flag+tool-count condition.
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
tool count is consulted once (an Effect) before the synchronous filter, and code mode
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
- **Description split on the `describeTask` precedent**: the tool's static base
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
`catalogInstructions` in the adapter) at the same composition point as task - so
`plugin.trigger("tool.definition")` sees the base description first.
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
hides a tool; ask-level rules stay fully visible and prompt at call time) and
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
registry before building the catalog/search index; `execute` rebuilds the runtime per
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
even if the model guesses its name and yields the normal unknown-tool diagnostic.
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
at request-prep after descriptions are built and has no child-call equivalent.
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
through `Tool.Context` exactly like every registry tool).
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
cheap (grouping + string rendering); revisit only if profiling shows it matters.
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
`define(...)` construction; description assertions target `catalogInstructions`, the
registry's composition input) and gained permission coverage: deny excluded from
catalog/search, ask-level stays visible and callable, denied tool undispatchable
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip
child calls" gap):
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
not about sessions or code mode.
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
and the hook payload cannot drift between callers. No callback/edge-firing design
was needed.
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
the entry key; `n` = per-execution counter starting at 1, shared across all child
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
`toolCallId` (`options.toolCallId`) stays each caller's existing value
(`ctx.callID ?? entry.key` for code mode).
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
hook failure fails ONLY that child call as a catchable in-program `toolError`; other
calls in the same program keep running and interruption still propagates as
interruption. Legacy semantics unchanged: a hook failure fails the tool call.
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
failing before hook is caught in-program, gates dispatch, and leaves the outer
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
45 + 16, session/tool/permission all green; this package untouched (211 pass).
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
not about sessions or code mode.
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
and the hook payload cannot drift between callers. No callback/edge-firing design
was needed.
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
the entry key; `n` = per-execution counter starting at 1, shared across all child
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
`toolCallId` (`options.toolCallId`) stays each caller's existing value
(`ctx.callID ?? entry.key` for code mode).
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
hook failure fails ONLY that child call as a catchable in-program `toolError`; other
calls in the same program keep running and interruption still propagates as
interruption. Legacy semantics unchanged: a hook failure fails the tool call.
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
failing before hook is caught in-program, gates dispatch, and leaves the outer
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
45 + 16, session/tool/permission all green; this package untouched (211 pass).
**Signature rendering + compound-assignment parity fixes** (externally reported, both
verified real with failing tests before fixing):
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
single `field` closure both the compact and pretty renderings share. The
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
etc.). The collapse is now restricted to Effect's number-schema artifact
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
compound assignment now rejects opaque references, consistent with binary operators.
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
Package suite: 220 pass.
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
single `field` closure both the compact and pretty renderings share. The
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
etc.). The collapse is now restricted to Effect's number-schema artifact
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
compound assignment now rejects opaque references, consistent with binary operators.
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
Package suite: 220 pass.
---
## 4. Remaining work (detailed TODO)
### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3)
Batch these together - per user direction: important, but deliberately deferred to one
focused interpreter-surface pass rather than picked off piecemeal.
- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain
`{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value -
`x instanceof Error` is unsupported syntax); `splice` (still a
@ -981,6 +1005,7 @@ focused interpreter-surface pass rather than picked off piecemeal.
(`console.log({ m: map })`) - could deep-format instead.
### Next iteration: text-result handling (deliberate follow-up, user-directed)
- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the
server sends it, else joined text as a plain string (the program JSON.parses it,
guided by a workflow step). Considered and deferred: (a) conservative boundary
@ -992,6 +1017,7 @@ focused interpreter-surface pass rather than picked off piecemeal.
revisit once real usage shows which failure modes matter.
### Next iteration: stdlib surface (prioritized)
Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is
intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host
runtime, but close the high-friction gaps models are likely to reach for.
@ -1028,7 +1054,9 @@ Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers
orchestration use case.
### Wiring-review findings (subagent code review of the OpenCode integration, triaged)
Pre-PR fixes (user-approved cut):
- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed
"user cancel interrupts the execution fiber," but `tools.ts` runs tools via
`run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring;
@ -1073,6 +1101,7 @@ Pre-PR fixes (user-approved cut):
`title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`.
Post-MVP (logged, not blocking an experimental flag):
- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP
registration fires them per tool (`tools.ts:419-441`); under code mode only the
outer `execute` fires them, so auditing/intercepting plugins silently lose MCP
@ -1105,6 +1134,7 @@ Post-MVP (logged, not blocking an experimental flag):
names that are no longer directly callable under code mode.
### Backlog / loose ends (non-blocking, any order)
- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a
sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++`
would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment
@ -1141,7 +1171,7 @@ Post-MVP (logged, not blocking an experimental flag):
## 5. Context and gotchas for whoever picks this up
- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript,
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* -
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ -
the old strict interpreter threw (`String property 'login' is not available`); then the
model returned a raw 105KB payload, which native truncation dumped to a file, costing a
subagent round-trip to extract one number. Interpreter forgiveness stops the crashes;

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,4 @@
export {
ToolError,
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
toolError,
} from "./codemode.js"
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
export { Tool } from "./tool.js"
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"

View file

@ -21,11 +21,16 @@ export type HostTools<R = never> = {
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
: never
: Tools extends object
? string extends keyof Tools
? never
: Services<Tools[keyof Tools]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
@ -68,11 +73,13 @@ export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode"
const defaultMaxInlineCatalogTokens = 2_000
const defaultSearchLimit = 10
const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const searchSignature =
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const toolExpression = (path: string) =>
"tools" + path
"tools" +
path
.split(".")
.map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
.map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`))
.join("")
export class ToolReference {
@ -88,7 +95,12 @@ const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
constructor(
readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
readonly kind:
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded",
message: string,
readonly suggestions: ReadonlyArray<string> = [],
) {
@ -131,7 +143,13 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
const copyBounded = (value: unknown, label: string, depth: number, seen: Set<object>, preserveSandboxValues: boolean): unknown => {
const copyBounded = (
value: unknown,
label: string,
depth: number,
seen: Set<object>,
preserveSandboxValues: boolean,
): unknown => {
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
@ -166,7 +184,12 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set<obj
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
// are never walked here (Map/Set members are validated where mutation happens, and the
// real boundary still serializes them below).
if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) {
if (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
) {
return value
}
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
@ -197,8 +220,12 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set<obj
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (
value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet ||
value instanceof RegExp || value instanceof Map || value instanceof Set
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
) {
return Object.create(null) as SafeObject
}
@ -250,7 +277,10 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
return value
}
const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
const definitions = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> => {
const entries: Array<{ path: string; definition: Definition<R> }> = []
for (const [name, value] of Object.entries(tools)) {
const next = [...path, name]
@ -342,8 +372,11 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
path,
definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property]),
].join("\n").toLowerCase(),
property === undefined ? [name] : [name, property],
),
]
.join("\n")
.toLowerCase(),
})
/** The runtime search index over every described tool. Search is always registered. */
@ -396,7 +429,8 @@ export const discoveryPlan = <R>(
namespace,
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
(left, right) =>
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
),
}))
let used = 0
@ -472,7 +506,9 @@ export const discoveryPlan = <R>(
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
...(complete
? []
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
]
const syntax = [
@ -500,26 +536,21 @@ export const discoveryPlan = <R>(
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
// Annotate only when a namespace is not fully shown, so a comprehensive
// namespace reads cleanly and a truncated one is unambiguous.
const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown`
const label =
picked.size === group.length
? count
: picked.size === 0
? `${count}, none shown`
: `${count}, ${picked.size} shown`
toolSection.push(`- ${namespace} (${label})`)
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push(
"",
"Search returns complete callable signatures:",
`- ${searchSignature}`,
)
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
}
}
const lines = [
...intro,
...workflow,
...rules,
...syntax,
...toolSection,
]
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection]
return {
catalog: described,
instructions: lines.join("\n"),
@ -534,18 +565,29 @@ export const discoveryPlan = <R>(
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
* discovery idioms, mirroring how calling an unknown tool fails.
*/
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): ReadonlyArray<string> => {
const namespaceKeys = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): ReadonlyArray<string> => {
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool namespace '${path.join(".")}'.`,
searchEnabled
? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."]
? [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
]
: ["Object.keys(tools) lists the available namespaces."],
)
}
@ -555,12 +597,25 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, sear
return Object.keys(value)
}
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): HostTool<R> | Definition<R> => {
const resolve = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [])
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool '${path.join(".")}'.`,
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
)
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
@ -602,7 +657,8 @@ export const make = <R>(
return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) =>
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })),
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }),
),
)
}
@ -624,7 +680,7 @@ export const make = <R>(
calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled),
invoke: (path, args) =>
Effect.gen(function*() {
Effect.gen(function* () {
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const call = { name }
@ -637,17 +693,32 @@ export const make = <R>(
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.")
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
)
}
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.")
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search query must be a string when provided.",
)
}
if (request.namespace !== undefined && typeof request.namespace !== "string") {
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.")
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search namespace must be a string when provided.",
)
}
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) {
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.")
if (
request.limit !== undefined &&
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search limit must be a positive safe integer when provided.",
)
}
const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
@ -656,40 +727,50 @@ export const make = <R>(
Effect.try({
try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
const scoped =
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
// A query that names one tool path exactly (canonical path or rendered
// JavaScript expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact = pathQuery === "" ? undefined : scoped.find((entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed)
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path
// segment (20) > path substring (8) > description substring (4) > any
// searchable text, incl. input parameter names/descriptions (2). Each term
// matches a field when any of its forms (the term or a singular variant)
// does. An empty query browses everything, alphabetical by path.
const ranked = exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.entry.description.path.localeCompare(right.entry.description.path),
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort((left, right) =>
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path))
.map(({ entry }) => entry)
.map(({ entry }) => entry)
// Result paths are rendered as JavaScript expressions so each `path` is
// directly usable as the call site (`await tools.github.list({ ... })` or
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
@ -711,10 +792,12 @@ export const make = <R>(
const tool = resolve(tools, path, searchEnabled)
let describedInput: unknown
if (isDefinition(tool)) {
if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
describedInput = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
catch: (cause) =>
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
}
const input = isDefinition(tool) ? describedInput : externalArgs
@ -722,7 +805,7 @@ export const make = <R>(
const currentCall = { index, name, input }
if (isDefinition(tool)) {
return yield* observeEnd(
Effect.gen(function*() {
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
@ -734,7 +817,7 @@ export const make = <R>(
)
}
return yield* observeEnd(
Effect.gen(function*() {
Effect.gen(function* () {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}),
currentCall,

View file

@ -57,8 +57,7 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top =>
Schema.isSchema(schema)
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
@ -69,10 +68,12 @@ const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unkn
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name)
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
const effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 &&
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/**
@ -118,7 +119,8 @@ const docTags = (schema: JsonSchema): Array<string> => {
*/
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""))
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
@ -127,7 +129,12 @@ const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet<string> = new Set()): string => {
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
if (schema.$ref) {
const name = schema.$ref.split("/").pop()
@ -146,13 +153,16 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
) return "number"
)
return "number"
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
if (
alternatives.length === 2 &&
alternatives[0]?.type === "object" && alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" && alternatives[1].items === undefined
alternatives[0]?.type === "object" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
@ -170,7 +180,8 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
@ -183,7 +194,9 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
// Pretty: an indented block, each described field preceded by its JSDoc comment.
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
@ -262,7 +275,9 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
* fields; the default stays the compact single-line form.
*/
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty)
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema

View file

@ -28,4 +28,7 @@ export class SandboxSet {
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet

View file

@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js"
import {
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
Tool,
toolError,
type ExecutionLimits,
} from "../src/index.js"
import type { Definition } from "../src/tool.js"
const run = (tool: Definition<never>) =>
@ -75,11 +82,14 @@ describe("CodeMode host failure boundary", () => {
output: Schema.Unknown,
run: () =>
Effect.succeed(
new Proxy({}, {
ownKeys: () => {
throw new Error("host-output-secret")
new Proxy(
{},
{
ownKeys: () => {
throw new Error("host-output-secret")
},
},
}),
),
),
}),
)
@ -162,9 +172,7 @@ describe("CodeMode tool-call observation", () => {
)
expect(result.ok).toBe(true)
expect(calls).toStrictEqual([
{ index: 0, name: "context.lookup", input: { query: "deployment failure" } },
])
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
})
test("observes settled calls with outcome and duration", async () => {
@ -173,25 +181,26 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) =>
query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query),
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : 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 }),
})
}),
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" })`))
@ -210,13 +219,15 @@ describe("CodeMode tool-call observation", () => {
describe("CodeMode console capture", () => {
test("captures console output as bounded result logs", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
const returned = console.log("Thread info:", { name: "Demo", count: 2 })
console.warn("careful")
return returned
`,
}))
}),
)
expect(result).toStrictEqual({
ok: true,
@ -228,39 +239,45 @@ describe("CodeMode console capture", () => {
})
test("keeps logs captured before failures", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.log("before failure")
throw new Error("boom")
`,
}))
}),
)
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
})
test("prints NaN and Infinity literally instead of the JSON null", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.log(NaN)
console.log(Infinity, -Infinity)
console.log({ ratio: NaN, bounds: [Infinity] })
return null
`,
}))
}),
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
})
test("renders sandbox values nested inside logged containers", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) })
console.log([new Date(0)])
return null
`,
}))
}),
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual([
@ -270,38 +287,40 @@ describe("CodeMode console capture", () => {
})
test("console formatting is total: cycles and opaque references render as markers", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
const m = new Map()
m.set("self", m)
console.log({ box: m })
console.log({ fn: (x) => x, ok: 1 })
return null
`,
}))
}),
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual([
'{"box":Map(1) [["self",[Circular]]]}',
'{"fn":[CodeMode reference],"ok":1}',
])
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
})
test("console.table renders sandbox value cells", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.table([{ when: new Date(0), n: NaN }])
return null
`,
}))
}),
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"])
})
test("captures console.dir and console.table output", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.dir({ nested: { ok: true } })
console.table([
{ name: "Kit", count: 1, hidden: "x" },
@ -309,15 +328,13 @@ describe("CodeMode console capture", () => {
], ["name", "count"])
return "done"
`,
}))
}),
)
expect(result).toStrictEqual({
ok: true,
value: "done",
logs: [
'{"nested":{"ok":true}}',
"(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2",
],
logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"],
toolCalls: [],
})
})
@ -325,9 +342,11 @@ describe("CodeMode console capture", () => {
describe("CodeMode output budget", () => {
test("absent maxOutputBytes means no truncation at all", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
}))
const result = await Effect.runPromise(
CodeMode.execute({
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
}),
)
expect(result.ok).toBe(true)
if (!result.ok) return
@ -338,29 +357,35 @@ describe("CodeMode output budget", () => {
test("truncates an oversized result value with a marker instead of failing", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(CodeMode.execute({
code: `return { data: "${"x".repeat(200)}" }`,
limits,
}))
const result = await Effect.runPromise(
CodeMode.execute({
code: `return { data: "${"x".repeat(200)}" }`,
limits,
}),
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.value).toMatch(/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/)
expect(result.value).toMatch(
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
)
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("keeps leading logs within the remaining budget and marks the cut", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.log("first line")
console.log("${"y".repeat(200)}")
return "ok"
`,
limits,
}))
limits,
}),
)
expect(result.ok).toBe(true)
if (!result.ok) return
@ -370,12 +395,14 @@ describe("CodeMode output budget", () => {
})
test("does not mark results within the budget", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `
const result = await Effect.runPromise(
CodeMode.execute({
code: `
console.log("fits")
return { fits: true }
`,
}))
}),
)
expect(result).toStrictEqual({
ok: true,
value: { fits: true },
@ -395,18 +422,21 @@ describe("CodeMode schema flexibility", () => {
properties: { id: { type: "string" }, count: { type: "number" } },
required: ["id"],
},
run: (input) => Effect.sync(() => {
observed.push(input)
return { echoed: input }
}),
run: (input) =>
Effect.sync(() => {
observed.push(input)
return { echoed: input }
}),
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
expect(runtime.catalog()).toStrictEqual([{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
}])
expect(runtime.catalog()).toStrictEqual([
{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
},
])
// JSON Schema is render-only: mistyped input passes through unvalidated.
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
@ -421,17 +451,25 @@ describe("CodeMode schema flexibility", () => {
input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] },
output: {
$ref: "#/$defs/User",
$defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } },
$defs: {
User: {
type: "object",
properties: { login: { type: "string" }, id: { type: "number" } },
required: ["login", "id"],
},
},
},
run: () => Effect.succeed({ login: "kit", id: 7 }),
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
expect(runtime.catalog()).toStrictEqual([{
path: "users.lookup",
description: "Look up a user",
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
}])
expect(runtime.catalog()).toStrictEqual([
{
path: "users.lookup",
description: "Look up a user",
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
},
])
const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`))
expect(result.ok).toBe(true)
@ -478,16 +516,20 @@ describe("CodeMode public contract", () => {
expect(agentTool.input).toBe(ExecuteInputSchema)
expect(agentTool.output).toBe(ExecuteResultSchema)
expect(agentTool.description).toBe(runtime.instructions())
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected)
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(
projected,
)
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([{
path: "orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
}])
expect(runtime.catalog()).toStrictEqual([
{
path: "orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
},
])
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
expect(runtime.instructions()).toContain("- orders (1 tool)")
expect(runtime.instructions()).toContain(
@ -502,11 +544,13 @@ describe("CodeMode public contract", () => {
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value).toStrictEqual({
items: [{
path: "tools.orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
}],
items: [
{
path: "tools.orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
},
],
total: 1,
})
}
@ -521,31 +565,43 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
expect(runtime.catalog()).toStrictEqual([{
path: "context7.resolve-library-id",
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
}])
expect(runtime.instructions()).toContain('tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>')
expect(runtime.catalog()).toStrictEqual([
{
path: "context7.resolve-library-id",
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
},
])
expect(runtime.instructions()).toContain(
'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
)
const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`))
const search = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
)
expect(search.ok).toBe(true)
if (search.ok) {
expect(search.value).toStrictEqual({
items: [{
path: 'tools.context7["resolve-library-id"]',
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
}],
items: [
{
path: 'tools.context7["resolve-library-id"]',
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
},
],
total: 1,
})
}
const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`))
const call = await Effect.runPromise(
runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`),
)
expect(call.ok).toBe(true)
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`))
const exact = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
)
expect(exact.ok).toBe(true)
if (exact.ok) expect((exact.value as { total: number }).total).toBe(1)
})
@ -561,7 +617,9 @@ describe("CodeMode public contract", () => {
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax"))
expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list"))
// The workflow carries the result-shape guidance; Rules only add content beyond it.
expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
expect(instructions).toContain(
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
)
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
@ -584,8 +642,12 @@ describe("CodeMode public contract", () => {
expect(partial).toContain(
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
)
expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`")
expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",
)
expect(partial).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(partial).not.toContain("total_count")
expect(partial).not.toContain("tools.orders.lookup({")
})
@ -604,7 +666,9 @@ describe("CodeMode public contract", () => {
expect(instructions).not.toContain("instanceof Error")
expect(instructions).not.toContain("splice")
// The data-boundary note survives.
expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.")
expect(instructions).toContain(
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
)
})
test("zero tools keep minimal sections and the no-tools notice", () => {
@ -635,18 +699,22 @@ describe("CodeMode public contract", () => {
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { maxInlineCatalogTokens: 0 },
})
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)")
expect(runtime.instructions()).toContain(
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
const result = await Effect.runPromise(runtime.execute(`
const result = await Effect.runPromise(
runtime.execute(`
return await tools.$codemode.search({
query: "send message attachment upload file to current Discord thread",
limit: 2
})
`))
`),
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toStrictEqual({
@ -666,19 +734,27 @@ describe("CodeMode public contract", () => {
})
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
const variants = await Effect.runPromise(runtime.execute(`
const variants = await Effect.runPromise(
runtime.execute(`
return await Promise.all([
tools.$codemode.search({ query: "file" }),
tools.$codemode.search({ query: "image" })
])
`))
`),
)
expect(variants.ok).toBe(true)
if (variants.ok) {
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile")
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage")
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe(
"tools.thread.uploadFile",
)
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe(
"tools.thread.generateImage",
)
}
const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`))
const removed = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
)
expect(removed.ok).toBe(false)
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
})
@ -706,15 +782,19 @@ describe("CodeMode public contract", () => {
}
for (const query of ["many.tool13", "tools.many.tool13"]) {
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`))
const exact = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
expect(exact.ok).toBe(true)
if (exact.ok) {
expect(exact.value).toStrictEqual({
items: [{
path: "tools.many.tool13",
description: "Numbered tool 13",
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
}],
items: [
{
path: "tools.many.tool13",
description: "Numbered tool 13",
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
},
],
total: 1,
})
}
@ -737,20 +817,23 @@ describe("CodeMode public contract", () => {
})
// Empty query + namespace browses just that namespace, alphabetical by path.
const browse = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "", namespace: "github" })`,
))
const browse = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
)
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2)
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"])
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.github.create_issue",
"tools.github.list_issues",
])
}
// A query + namespace ranks within that namespace only.
const scoped = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`,
))
const scoped = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
)
expect(scoped.ok).toBe(true)
if (scoped.ok) {
const value = scoped.value as { items: Array<{ path: string }>; total: number }
@ -758,9 +841,9 @@ describe("CodeMode public contract", () => {
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
}
const invalid = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "issues", namespace: 7 })`,
))
const invalid = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
)
expect(invalid.ok).toBe(false)
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
})
@ -785,9 +868,9 @@ describe("CodeMode public contract", () => {
// "attachment" appears in neither path nor description - only in the input schema's
// property names, which the searchable text includes.
const byParameter = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "attachment" })`,
))
const byParameter = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
)
expect(byParameter.ok).toBe(true)
if (byParameter.ok) {
const value = byParameter.value as { items: Array<{ path: string }>; total: number }
@ -796,9 +879,9 @@ describe("CodeMode public contract", () => {
}
// Substring matching: a partial word ("docum") still hits the description.
const bySubstring = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "docum" })`,
))
const bySubstring = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
)
expect(bySubstring.ok).toBe(true)
if (bySubstring.ok) {
const value = bySubstring.value as { items: Array<{ path: string }>; total: number }
@ -825,9 +908,9 @@ describe("CodeMode public contract", () => {
})
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
const plural = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`,
))
const plural = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
)
expect(plural.ok).toBe(true)
if (plural.ok) {
const value = plural.value as { items: Array<{ path: string }>; total: number }
@ -836,14 +919,15 @@ describe("CodeMode public contract", () => {
}
// ...while a true "issues" path match still outranks the singular-only description match.
const ranked = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "issues" })`,
))
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2)
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"])
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.github.list_issues",
"tools.tracker.fetch_all",
])
}
})
@ -882,8 +966,12 @@ describe("CodeMode public contract", () => {
run: () => Effect.succeed("ok"),
})
const expensive = Tool.make({
description: "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
input: Schema.Struct({ someRatherLongParameterName: Schema.String, anotherEvenLongerParameterName: Schema.Number }),
description:
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
input: Schema.Struct({
someRatherLongParameterName: Schema.String,
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String,
run: () => Effect.succeed("ok"),
})
@ -896,7 +984,9 @@ describe("CodeMode public contract", () => {
})
const instructions = runtime.instructions()
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)")
expect(instructions).toContain(
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(")
@ -912,10 +1002,11 @@ describe("CodeMode public contract", () => {
description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString,
run: ({ value }) => Effect.sync(() => {
observed.push(value)
return String(value * 2)
}),
run: ({ value }) =>
Effect.sync(() => {
observed.push(value)
return String(value * 2)
}),
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
@ -934,9 +1025,11 @@ describe("CodeMode public contract", () => {
})
test("returns JSON-safe data and normalizes undefined to null", async () => {
const result = await Effect.runPromise(CodeMode.execute({
code: `return { top: undefined, nested: [1, undefined] }`,
}))
const result = await Effect.runPromise(
CodeMode.execute({
code: `return { top: undefined, nested: [1, undefined] }`,
}),
)
expect(result).toStrictEqual({
ok: true,
value: { top: null, nested: [1, null] },
@ -947,18 +1040,20 @@ describe("CodeMode public contract", () => {
test("rejects invalid configuration and discovery limits", async () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
RangeError,
)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError)
const result = await Effect.runPromise(CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 0 },
}).execute(
`return await tools.$codemode.search({ query: "order", limit: 0.5 })`,
))
const result = await Effect.runPromise(
CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 0 },
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("InvalidToolInput")
@ -979,14 +1074,16 @@ describe("CodeMode public contract", () => {
output: Schema.Number,
run: () => Effect.succeed(1),
})
const result = await Effect.runPromise(CodeMode.execute({
tools: { host: { count: counter } },
code: `
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { count: counter } },
code: `
let total = 0
for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
return total
`,
}))
}),
)
expect(result).toMatchObject({ ok: true, value: 150 })
if (result.ok) expect(result.toolCalls.length).toBe(150)
})
@ -1008,8 +1105,6 @@ describe("CodeMode public contract", () => {
})
test("reserves the discovery namespace", () => {
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(
/reserved for CodeMode discovery tools/,
)
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/)
})
})

View file

@ -36,10 +36,12 @@ const error = async (code: string) => {
describe("Object.keys over tool references", () => {
test("enumerates top-level namespaces (the transcript program)", async () => {
expect(await value(`
expect(
await value(`
const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length }
`)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
`),
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
})
test("enumerates tool names at a nested namespace", async () => {
@ -92,7 +94,8 @@ describe("Object.keys over arrays", () => {
describe("for...in", () => {
test("iterates own enumerable keys of a plain object with break/continue", async () => {
expect(await value(`
expect(
await value(`
const seen = []
for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
if (key === "b") continue
@ -100,41 +103,50 @@ describe("for...in", () => {
seen.push(key)
}
return seen
`)).toEqual(["a", "c"])
`),
).toEqual(["a", "c"])
})
test("iterates index strings over arrays", async () => {
expect(await value(`
expect(
await value(`
const indexes = []
for (const i in ["x", "y", "z"]) {
if (i === "2") break
indexes.push(i)
}
return indexes
`)).toEqual(["0", "1"])
`),
).toEqual(["0", "1"])
})
test("supports let declarations and bare identifiers", async () => {
expect(await value(`
expect(
await value(`
let last = ""
for (let key in { a: 1, b: 2 }) last = key
return last
`)).toBe("b")
expect(await value(`
`),
).toBe("b")
expect(
await value(`
let key = "before"
for (key in { only: 1 }) {}
return key
`)).toBe("only")
`),
).toBe("only")
})
test("enumerates namespaces and tools from the host tool tree", async () => {
expect(await value(`
expect(
await value(`
const names = []
for (const ns in tools) {
for (const name in tools[ns]) names.push(ns + "." + name)
}
return names
`)).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
`),
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {

View file

@ -143,21 +143,40 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
describe("Error values and instanceof", () => {
test("new Error carries name/message and is instanceof Error", async () => {
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"])
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([
true,
"Error",
"boom",
])
})
test("Error without new behaves like new Error", async () => {
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "plain"])
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual(["Error", "", true])
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([
true,
"Error",
"plain",
])
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
"Error",
"",
true,
])
})
test("specific error types are instanceof themselves and Error, not each other", async () => {
expect(await value(`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`)).toEqual([true, true, false])
expect(
await value(
`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
),
).toEqual([true, true, false])
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"])
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
"x",
])
})
test("interpreter runtime failures are caught as Error values", async () => {
@ -168,33 +187,48 @@ describe("Error values and instanceof", () => {
test("caught failures carry the constructor name the real-JS failure would have", async () => {
// JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
// message keeps the engine's position detail.
expect(await value(`
expect(
await value(`
try { JSON.parse("{oops") } catch (e) {
return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
}
`)).toEqual(["SyntaxError", true, true, false, true])
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`))
.toEqual(["ReferenceError", true])
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`))
.toEqual(["TypeError", true])
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`))
.toEqual(["RangeError", true])
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
.toEqual(["SyntaxError", true])
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
.toEqual(["SyntaxError", true])
`),
).toEqual(["SyntaxError", true, true, false, true])
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
"ReferenceError",
true,
])
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
"TypeError",
true,
])
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual(
["RangeError", true],
)
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
})
test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`))
.toEqual(["Error", true])
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
"Error",
true,
])
})
test("Promise.allSettled rejection reasons are Error values", async () => {
expect(await value(`
expect(
await value(`
const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
return [settled[0].reason instanceof Error, settled[0].reason.message]
`)).toEqual([true, "b"])
`),
).toEqual([true, "b"])
})
test("non-error thrown values are not instanceof Error", async () => {
@ -203,7 +237,11 @@ describe("Error values and instanceof", () => {
})
test("plain data is never instanceof Error", async () => {
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false])
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
false,
false,
false,
])
})
test("error values still serialize as plain { name, message } data", async () => {
@ -227,17 +265,29 @@ describe("Error values and instanceof", () => {
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
test("splice removes in place and returns the removed elements", async () => {
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1, 4] })
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({
removed: [2, 3],
a: [1, 4],
})
})
test("splice inserts new elements at the cut", async () => {
expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], a: [1, "x", 3] })
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
removed: [2],
a: [1, "x", 3],
})
})
test("splice with one argument removes to the end; negative start counts back", async () => {
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1] })
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ removed: [3], a: [1, 2] })
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({
removed: [2, 3],
a: [1],
})
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
removed: [3],
a: [1, 2],
})
})
test("splice rejects inserting a container into itself", async () => {
@ -258,11 +308,13 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
test("keys/values/entries return arrays usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
expect(await value(`
expect(
await value(`
const out = []
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
return out
`)).toEqual(["0:a", "1:b"])
`),
).toEqual(["0:a", "1:b"])
expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
})
})
@ -300,40 +352,33 @@ describe("compound assignment matches its binary operator", () => {
}
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
const result = await pair(
`let d = new Date(1000); d += 1; return d`,
`let d = new Date(1000); d = d + 1; return d`,
)
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
expect(result).toBe("1970-01-01T00:00:01.000Z1")
})
test("sandbox Date numeric compound ops use its time value", async () => {
expect(await pair(
`let d = new Date(1000); d -= 400; return d`,
`let d = new Date(1000); d = d - 400; return d`,
)).toBe(600)
expect(await pair(
`let d = new Date(1000); d /= 4; return d`,
`let d = new Date(1000); d = d / 4; return d`,
)).toBe(250)
expect(
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
).toBe(600)
expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
250,
)
})
test("string += object/array matches x = x + obj", async () => {
expect(await pair(
`let x = "a"; x += { b: 1 }; return x`,
`let x = "a"; x = x + { b: 1 }; return x`,
)).toBe("a[object Object]")
expect(await pair(
`let x = "a"; x += [1, 2]; return x`,
`let x = "a"; x = x + [1, 2]; return x`,
)).toBe("a1,2")
expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe(
"a[object Object]",
)
expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2")
})
test("compound assignment through a member target coerces the same way", async () => {
expect(await pair(
`const o = { s: "t" }; o.s += new Date(0); return o.s`,
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
)).toBe("t1970-01-01T00:00:00.000Z")
expect(
await pair(
`const o = { s: "t" }; o.s += new Date(0); return o.s`,
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
),
).toBe("t1970-01-01T00:00:00.000Z")
})
test("numeric and string compound operators sweep identically to their expansions", async () => {

View file

@ -23,7 +23,7 @@ const sleepyTool = (trace: Trace) =>
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
output: Schema.Number,
run: ({ id, ms }) =>
Effect.gen(function*() {
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active)
@ -31,10 +31,14 @@ const sleepyTool = (trace: Trace) =>
trace.active -= 1
trace.completed += 1
return id
}).pipe(Effect.onInterrupt(() => Effect.sync(() => {
trace.active -= 1
trace.interrupted += 1
}))),
}).pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
trace.active -= 1
trace.interrupted += 1
}),
),
),
})
const failingTool = Tool.make({
@ -46,11 +50,13 @@ const failingTool = Tool.make({
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
const trace = options.trace ?? makeTrace()
return Effect.runPromise(CodeMode.execute({
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
code,
...(options.limits ? { limits: options.limits } : {}),
}))
return Effect.runPromise(
CodeMode.execute({
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
code,
...(options.limits ? { limits: options.limits } : {}),
}),
)
}
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
@ -121,7 +127,8 @@ describe("first-class promise values", () => {
})
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
expect(await value(`
expect(
await value(`
const p = tools.host.fail({})
try {
await p
@ -129,7 +136,8 @@ describe("first-class promise values", () => {
} catch (e) {
return e.message
}
`)).toBe("Lookup refused")
`),
).toBe("Lookup refused")
})
test("a fire-and-forget call completes before the execution ends", async () => {
@ -186,20 +194,24 @@ describe("promises at data boundaries", () => {
describe("Promise.all over arbitrary arrays", () => {
test("mixes promises and plain values, preserving order", async () => {
expect(await value(`
expect(
await value(`
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
`)).toEqual([1, "plain", 2, 42])
`),
).toEqual([1, "plain", 2, 42])
})
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
expect(await value(`
expect(
await value(`
const calls = []
calls.push(tools.host.sleepy({ id: 1 }))
calls.push(7)
const more = [tools.host.sleepy({ id: 2 })]
const batch = [...calls, ...more, "x"]
return await Promise.all(batch)
`)).toEqual([1, 7, 2, "x"])
`),
).toEqual([1, 7, 2, "x"])
})
test("runs items.map tool calls in parallel", async () => {
@ -238,14 +250,16 @@ describe("Promise.all over arbitrary arrays", () => {
})
test("rejects with the first failure, catchable in-program", async () => {
expect(await value(`
expect(
await value(`
try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
return "no"
} catch (e) {
return e.message
}
`)).toBe("Lookup refused")
`),
).toBe("Lookup refused")
})
test("a non-collection argument is a clear error", async () => {
@ -264,14 +278,16 @@ describe("Promise.all over arbitrary arrays", () => {
describe("Promise.allSettled", () => {
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
expect(await value(`
expect(
await value(`
return await Promise.allSettled([
tools.host.sleepy({ id: 5 }),
tools.host.fail({}),
"plain",
Promise.reject(new Error("boom")),
])
`)).toEqual([
`),
).toEqual([
{ status: "fulfilled", value: 5 },
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
{ status: "fulfilled", value: "plain" },
@ -306,7 +322,8 @@ describe("Promise.race", () => {
})
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
expect(await value(`
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow])
@ -316,23 +333,31 @@ describe("Promise.race", () => {
} catch (e) {
return { winner, caught: e.message }
}
`)).toEqual({ winner: 1, caught: "This tool call was interrupted because another value settled a Promise.race first." })
`),
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
})
test("a rejection can win the race", async () => {
expect(await value(`
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no"
} catch (e) {
return e.message
}
`)).toBe("Lookup refused")
`),
).toBe("Lookup refused")
})
test("a plain value wins over pending promises", async () => {
const trace = makeTrace()
expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate")
expect(
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.interrupted).toBe(1)
})
@ -350,14 +375,16 @@ describe("Promise.resolve / Promise.reject", () => {
})
test("reject produces a promise whose await throws the reason", async () => {
expect(await value(`
expect(
await value(`
try {
await Promise.reject("nope")
return "no"
} catch (e) {
return e
}
`)).toBe("nope")
`),
).toBe("nope")
})
})

View file

@ -83,15 +83,9 @@ describe("pretty signature rendering", () => {
true,
)
expect(pretty).toBe(
[
"{",
" /** Search filter */",
" filter?: {",
" /** Issue state */",
" state?: string",
" }",
"}",
].join("\n"),
["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join(
"\n",
),
)
})
@ -119,7 +113,14 @@ describe("pretty signature rendering", () => {
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(
[" /**", ' * @default ["a","b"]', " * @minItems 2", " * @maxItems 5", " */", " tags?: Array<string>"].join("\n"),
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
})
@ -212,7 +213,11 @@ describe("non-identifier property names render as quoted keys", () => {
const tool = Tool.make({
description: "Adapter tool with awkward field names",
input: rawSchema,
output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const,
output: {
type: "object",
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
@ -269,9 +274,9 @@ describe("pretty signatures in search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
const result = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`,
))
const result = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
return result.value as { items: Array<{ path: string; signature: string }>; total: number }

View file

@ -40,7 +40,11 @@ describe("Date", () => {
})
test("UTC getters read calendar components", async () => {
expect(await value(`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`)).toEqual([2024, 2, 5, 6, 7, 8, 9])
expect(
await value(
`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`,
),
).toEqual([2024, 2, 5, 6, 7, 8, 9])
})
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
@ -49,7 +53,9 @@ describe("Date", () => {
})
test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught")
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
"caught",
)
})
test("template interpolation renders the ISO form", async () => {
@ -72,14 +78,18 @@ describe("Date", () => {
})
test("sorting dates with a numeric comparator", async () => {
expect(await value(`
expect(
await value(`
const dates = [new Date(3000), new Date(1000), new Date(2000)]
return dates.sort((a, b) => a - b).map((d) => d.getTime())
`)).toEqual([1000, 2000, 3000])
`),
).toEqual([1000, 2000, 3000])
})
test("new Date(year, month, day) accepts component form", async () => {
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2])
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([
2024, 0, 2,
])
})
test("typeof and unknown properties are forgiving", async () => {
@ -95,25 +105,31 @@ describe("RegExp", () => {
})
test("exec exposes captures and index", async () => {
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({
full: "abb",
group: "bb",
index: 2,
})
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual(
{
full: "abb",
group: "bb",
index: 2,
},
)
expect(await value(`return /a/.exec("zzz")`)).toBeNull()
})
test("named groups read through", async () => {
expect(await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`)).toBe("ab42")
expect(
await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`),
).toBe("ab42")
})
test("global exec advances lastIndex across calls", async () => {
expect(await value(`
expect(
await value(`
const r = /\\d+/g
const first = r.exec("a1b22c")
const second = r.exec("a1b22c")
return [first[0], second[0]]
`)).toEqual(["1", "22"])
`),
).toEqual(["1", "22"])
})
test("string match: non-global carries index, global lists all matches", async () => {
@ -194,34 +210,51 @@ describe("RegExp", () => {
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(await value(`
expect(
await value(`
const m = new Map()
m.set("a", 1).set("b", 2)
return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size }
`)).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
`),
).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
})
test("object keys use identity", async () => {
expect(await value(`
expect(
await value(`
const key = { id: 1 }
const m = new Map()
m.set(key, "hit")
return [m.get(key), m.get({ id: 1 }) === undefined]
`)).toEqual(["hit", true])
`),
).toEqual(["hit", true])
})
test("construction from entry pairs and another Map", async () => {
expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2)
expect(await value(`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`)).toEqual([1, 2, false])
expect(
await value(
`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`,
),
).toEqual([1, 2, false])
expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/)
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
})
test("keys/values/entries return arrays", async () => {
expect(await value(`
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
return { keys: m.keys(), values: m.values(), entries: m.entries() }
`)).toEqual({ keys: ["a", "b"], values: [1, 2], entries: [["a", 1], ["b", 2]] })
`),
).toEqual({
keys: ["a", "b"],
values: [1, 2],
entries: [
["a", 1],
["b", 2],
],
})
})
test("Object.fromEntries(map) and Array.from(map)", async () => {
@ -230,13 +263,15 @@ describe("Map", () => {
})
test("for...of iterates [key, value] pairs with destructuring", async () => {
expect(await value(`
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
let total = 0
let names = ""
for (const [key, count] of m) { names += key; total += count }
return names + total
`)).toBe("ab3")
`),
).toBe("ab3")
})
test("spread produces entry pairs", async () => {
@ -244,32 +279,38 @@ describe("Map", () => {
})
test("forEach passes (value, key)", async () => {
expect(await value(`
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
const seen = []
m.forEach((count, key) => seen.push(key + count))
return seen
`)).toEqual(["a1", "b2"])
`),
).toEqual(["a1", "b2"])
})
test("delete and clear", async () => {
expect(await value(`
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
const removed = m.delete("a")
const missed = m.delete("zz")
const sizeAfterDelete = m.size
m.clear()
return [removed, missed, sizeAfterDelete, m.size]
`)).toEqual([true, false, 1, 0])
`),
).toEqual([true, false, 1, 0])
})
test("counting idiom: grouped tallies", async () => {
expect(await value(`
expect(
await value(`
const words = ["a", "b", "a", "c", "a"]
const counts = new Map()
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
return Object.fromEntries(counts)
`)).toEqual({ a: 3, b: 1, c: 1 })
`),
).toEqual({ a: 3, b: 1, c: 1 })
})
test("maps serialize to {} at the boundary, like JSON", async () => {
@ -286,12 +327,14 @@ describe("Map", () => {
describe("Set", () => {
test("add/has/delete/size with chaining", async () => {
expect(await value(`
expect(
await value(`
const s = new Set()
s.add(1).add(2).add(1)
const removed = s.delete(2)
return [s.size, s.has(1), s.has(2), removed]
`)).toEqual([1, true, false, true])
`),
).toEqual([1, true, false, true])
})
test("dedupe idiom: [...new Set(items)]", async () => {
@ -308,11 +351,13 @@ describe("Set", () => {
})
test("for...of iterates values", async () => {
expect(await value(`
expect(
await value(`
let total = 0
for (const n of new Set([1, 2, 3])) total += n
return total
`)).toBe(6)
`),
).toBe(6)
})
test("sets serialize to {} at the boundary, like JSON", async () => {
@ -338,21 +383,32 @@ describe("stdlib integration", () => {
})
test("dates inside Map values survive in-sandbox reads", async () => {
expect(await value(`
expect(
await value(`
const m = new Map([["start", new Date(1000)]])
return m.get("start").getTime()
`)).toBe(1000)
`),
).toBe(1000)
})
test("instanceof recognizes the stdlib value types", async () => {
expect(await value(`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`)).toEqual([true, true, true, true])
expect(await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`)).toEqual([true, true, true, false])
expect(
await value(
`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`,
),
).toEqual([true, true, true, true])
expect(
await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`),
).toEqual([true, true, true, false])
expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false])
expect(await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`)).toBe(true)
expect(
await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`),
).toBe(true)
})
test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => {
expect(await value(`
expect(
await value(`
const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]'
const rows = JSON.parse(raw)
const tags = new Set()
@ -363,27 +419,34 @@ describe("stdlib integration", () => {
byDay.set(day, (byDay.get(day) ?? 0) + 1)
}
return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) }
`)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
`),
).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
})
})
describe("sandbox values at intra-sandbox checkpoints", () => {
test("Object.values/entries keep Dates usable", async () => {
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0")
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
"d:0",
)
})
test("Object.assign keeps Maps usable", async () => {
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1)
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(
1,
)
})
test("object and array spread keep sandbox values usable", async () => {
expect(await value(`
expect(
await value(`
const src = { m: new Map([["a", 1]]) }
const copy = { ...src }
copy.m.set("b", 2)
return [copy.m.get("a"), src.m.get("b")]
`)).toEqual([1, 2])
`),
).toEqual([1, 2])
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
})
@ -404,7 +467,10 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
})
test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => {
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ d: "1970-01-01T00:00:00.000Z", m: {} })
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({
d: "1970-01-01T00:00:00.000Z",
m: {},
})
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
const observed: Array<unknown> = []
@ -417,10 +483,12 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
return "ok"
}),
})
const result = await Effect.runPromise(CodeMode.execute({
tools: { host: { capture } },
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
}))
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { capture } },
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
}),
)
expect(result.ok).toBe(true)
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
})