mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:08:27 +00:00
chore: generate
This commit is contained in:
parent
2409c7a3d5
commit
3f0fc22b6e
14 changed files with 2091 additions and 1173 deletions
1
bun.lock
1
bun.lock
|
|
@ -594,7 +594,6 @@
|
|||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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: {} }])
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue