Compare commits

..

53 Commits

Author SHA1 Message Date
Aiden Cline e0a4fecd5b fix(tui): refresh MCP data for default location 2026-07-14 07:25:16 +00:00
Dax Raad 9b8282ad3d feat(plugin): add v2 tui entrypoint 2026-07-13 23:08:34 -04:00
Dax Raad 2a08cd3b96 refactor(core): simplify plugin entrypoint resolution 2026-07-13 22:45:15 -04:00
Dax Raad 5cf24bf185 tui: prevent crash when opening session switcher from an active session 2026-07-13 21:05:03 -04:00
Aiden Cline c15e3487b2 test(codemode): make promise tests deterministic (#36780) 2026-07-13 18:53:20 -05:00
Aiden Cline 6963f2f6da fix(cli): update mini tests for v2 types (#36779) 2026-07-13 18:38:25 -05:00
Aiden Cline 547e0148c7 chore(codemode): run tests in CI (#36772) 2026-07-13 18:09:10 -05:00
Dax Raad 358d4746a9 refactor(cli): remove legacy sdk dependency 2026-07-13 18:03:16 -04:00
Kit Langton ce2e301e24 feat(tui): style background subagent indicator (#36751) 2026-07-13 17:57:12 -04:00
opencode-agent[bot] fbe7f26e71 fix(core): clamp compaction output budget (#36745)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 17:50:20 -04:00
Aiden Cline ecb5754f4c refactor(codemode): rename Sandbox terminology to CodeMode (#36768) 2026-07-13 16:38:11 -05:00
Dax Raad 36f8cb7054 refactor(tui): remove legacy sdk client 2026-07-13 17:35:40 -04:00
James Long 4c45a5cf23 refactor(tui): extract system theme generation (#36753) 2026-07-13 16:39:06 -04:00
James Long 748b0d8836 refactor(tui): remove subtle syntax styles (#36746) 2026-07-13 16:14:56 -04:00
Kit Langton ed1636e3bd fix(tui): clamp dialog selection after options shrink (#36712) 2026-07-13 15:59:04 -04:00
Dax Raad 90a87b2a0c refactor(tui): remove legacy sync context 2026-07-13 15:46:25 -04:00
Kit Langton af8480b2e2 fix(tui): pin queued compaction below output (#36738) 2026-07-13 15:45:27 -04:00
Kit Langton 2096adae12 refactor(tui): remove unused subagent formatters (#36732) 2026-07-13 15:20:03 -04:00
Kit Langton 41349ff20a fix(tui): run dialog actions without selection (#36711) 2026-07-13 15:19:40 -04:00
Kit Langton a2f5a0df5d fix(core): compact after tool settlement (#36718) 2026-07-13 15:17:45 -04:00
Kit Langton 3bc924252b fix(www): generate collections before typecheck (#36730) 2026-07-13 15:07:45 -04:00
Kit Langton 8c1808ab74 fix(tui): standardize OpenCode product casing (#36717) 2026-07-13 15:01:48 -04:00
Dax Raad 685f887771 docs: clarify embedded sdk roadmap 2026-07-13 14:54:40 -04:00
Dax Raad 9db5073eab fix(plugin): retain legacy kv api shape 2026-07-13 12:57:31 -04:00
Dax Raad 775fce0177 refactor(tui): consolidate settings in cli config 2026-07-13 12:55:54 -04:00
Dax Raad 4e74f77e44 delete context.md 2026-07-13 11:49:34 -04:00
Dax Raad b980f74ed9 refactor(tui): reuse dialog select for settings 2026-07-13 11:16:05 -04:00
opencode-agent[bot] 2987c30238 revert(tui): restore list highlights (#36680)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:29:44 -04:00
opencode-agent[bot] 50a1add183 fix(tui): open model selector at top (#36679)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 10:20:15 -04:00
Dax Raad 3533047421 sync 2026-07-13 10:19:04 -04:00
opencode-agent[bot] 823bc20427 fix(tui): use gray list highlights (#36669)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:15:30 -04:00
opencode-agent[bot] 339fd50cb9 fix(tui): preserve modal focus across dialog replacement (#36656)
Co-authored-by: Simon Klee <52795+simonklee@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 14:54:59 +02:00
Aiden Cline 828a4b3f15 fix(codemode): align array callback behavior (#36584) 2026-07-13 00:34:18 -05:00
Dax Raad a43de3f86d tui: remove legacy V1 CLI TUI integration to simplify startup 2026-07-13 01:05:14 -04:00
Dax Raad fe3b2e8f90 sync 2026-07-13 00:50:51 -04:00
Dax Raad 20af1f5e79 refactor(tui): compact settings values 2026-07-13 03:57:14 +00:00
Kit Langton 8daf912fbf feat(tui): add settings dialog 2026-07-13 03:57:14 +00:00
opencode-agent[bot] 3d545f960b fix(tui): restore clicked reverted prompt (#36567)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 21:51:50 -05:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
Aiden Cline 64ca9b8d77 refactor(core): replace deferred tool option with codemode (#36560) 2026-07-12 21:21:07 -05:00
Kit Langton a4b91d33d9 fix(tui): align skills dialog copy (#36592) 2026-07-12 22:02:24 -04:00
Kit Langton 397993e898 fix(client): preserve compatible background service (#36583) 2026-07-12 21:32:34 -04:00
Kit Langton c0ed0106b1 fix(tui): show background shell completion (#36534) 2026-07-12 21:01:36 -04:00
opencode-agent[bot] f112a73c06 fix(core): load config across git boundaries (#36577)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 00:05:41 +00:00
Dax Raad 7e9b9cb0fd docs: document TUI config migration 2026-07-12 19:28:26 -04:00
Dax Raad 09a6cecf23 refactor(tui): remove code concealment toggle 2026-07-12 18:12:36 -04:00
opencode-agent[bot] 6e55ddd078 fix(config): load configs across git boundaries (#36568)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-12 22:07:20 +00:00
Dax Raad f8f8cc9546 refactor(tui): simplify config context 2026-07-12 17:12:48 -04:00
Aiden Cline 3c60470269 refactor(codemode): remove tool concurrency cap (#36545) 2026-07-12 16:07:53 -05:00
Dax Raad b2bdab24e6 fix(tui): preserve legacy host compatibility 2026-07-12 17:01:02 -04:00
Dax Raad 3568dd1b99 feat(tui): add managed config interface 2026-07-12 17:01:02 -04:00
opencode-agent[bot] 1e17202413 chore: merge dev into v2 (#36556)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
2026-07-12 15:16:08 -05:00
Aiden Cline 49cb73b348 refactor(codemode): split runtime into focused interpreter modules (#36540) 2026-07-12 12:47:56 -05:00
288 changed files with 204968 additions and 118621 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/client": patch
---
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
-245
View File
@@ -1,245 +0,0 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**Model Context**:
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
_Avoid_: System Context
**Instructions**:
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
_Avoid_: Session Context
**Instruction Source**:
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**InstructionEntry**:
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**Instruction State**:
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
**Instruction Update**:
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
_Avoid_: Correction, stored prose, raw text diff
**Initial Instructions**:
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Instruction Epoch**:
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
**Instruction Values**:
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
**Unavailable Instruction Source**:
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Step**:
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
**Assistant Turn**:
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
**Settlement**:
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
**Execution**:
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
+970 -17
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
@@ -102,6 +103,8 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
+4 -4
View File
@@ -65,8 +65,8 @@ export const dict = {
"command.message.next.description": "Go to the next user message",
"command.model.choose": "Choose model",
"command.model.choose.description": "Select a different model",
"command.mcp.toggle": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"command.mcp.toggle": "Manage MCP servers",
"command.mcp.toggle.description": "Enable or disable MCP servers",
"command.agent.cycle": "Cycle agent",
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
@@ -307,9 +307,9 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
"dialog.mcp.title": "MCPs",
"dialog.mcp.title": "MCP servers",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.mcp.empty": "No MCP servers configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
+1 -1
View File
@@ -36,7 +36,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
@@ -45,6 +44,7 @@
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"opentui-spinner": "catalog:",
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -12,11 +12,11 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { TuiConfig } from "../../tui-config"
import { Config } from "../../config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
@@ -35,13 +35,18 @@ export default Runtime.handler(Commands, (input) =>
),
)
preflight.loading()
const config = yield* TuiConfig.load()
const config = yield* Config.Service
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
yield* run({
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
config: {
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
+11 -11
View File
@@ -1,11 +1,11 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import {
createOpencodeClient,
OpenCode,
type IntegrationAttemptStatus,
type IntegrationOAuthMethod,
type OpencodeClient,
} from "@opencode-ai/sdk/v2/client"
type OpenCodeClient,
} from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -20,7 +20,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration)
@@ -32,10 +32,9 @@ export default Runtime.handler(
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() =>
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
)
const attempt = started.data?.data
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
const attempt = started.data
if (attempt.mode === "code")
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
@@ -52,13 +51,14 @@ export default Runtime.handler(
)
const poll = (
client: OpencodeClient,
client: OpenCodeClient,
attemptID: string,
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
const status = response.data?.data
if (!status || status.status === "pending") {
const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
Effect.map((result) => result.data),
)
if (status.status === "pending") {
yield* Effect.sleep("1 second")
return yield* poll(client, attemptID)
}
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
import { OpenCode, type McpServer } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -12,9 +12,9 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) {
process.stdout.write("No MCP servers configured" + EOL)
return
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -15,7 +15,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) {
@@ -31,7 +31,7 @@ export default Runtime.handler(
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true },
)
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
@@ -1,17 +1,18 @@
import { Effect } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { OpenCodeClient } from "@opencode-ai/client"
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
// or anonymous server), leaving that case for the caller to interpret.
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
export const resolveIntegration = (client: OpenCodeClient, name: string, location: { directory: string }) =>
Effect.gen(function* () {
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
const servers = yield* Effect.promise(() => client.mcp.list({ location }))
const server = servers.data.find((entry) => entry.name === name)
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
const integrationID = server.integrationID
if (!integrationID) return undefined
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
return found.data?.data
return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
Effect.map((result) => result.data ?? undefined),
)
})
+109
View File
@@ -0,0 +1,109 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
export * from "./schema"
export interface Interface {
readonly path: string
readonly get: () => Effect.Effect<Info>
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const empty: Info = {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})
const write = Effect.fnUntraced(function* (text: string) {
const temp = file + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, file)
})
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
}),
)
type Edit = { readonly path: (string | number)[]; readonly value: any }
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}
+1
View File
@@ -0,0 +1 @@
export * as Config from "./config"
+142
View File
@@ -0,0 +1,142 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
readonly config: string
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
})
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
) ?? []),
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
]
const themeName = legacy?.theme ?? kv.theme
const themeMode = kv.theme_mode_lock
const attentionSoundPack = kv.attention_sound_pack
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ??
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
return {
...(themeName !== undefined || themeMode !== undefined
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
? {}
: {
scroll: {
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
...(legacy.scroll_acceleration?.enabled === undefined
? {}
: { acceleration: legacy.scroll_acceleration.enabled }),
},
}),
...(legacy?.attention === undefined && attentionSoundPack === undefined
? {}
: {
attention: {
...legacy?.attention,
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
},
}),
...(legacy?.diff_style === undefined &&
kv.diff_wrap_mode === undefined &&
kv.diff_viewer_show_file_tree === undefined &&
kv.diff_viewer_single_patch === undefined &&
diffView === undefined
? {}
: {
diffs: {
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
...(diffView === undefined ? {} : { view: diffView }),
},
}),
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
? {}
: {
prompt: {
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
...(kv.paste_summary_enabled === undefined
? {}
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
},
}),
...(kv.sidebar === undefined &&
kv.scrollbar_visible === undefined &&
thinking === undefined &&
kv.exploration_grouping === undefined
? {}
: {
session: {
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
...(thinking === undefined ? {} : { thinking }),
...(kv.exploration_grouping === undefined
? {}
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
? {}
: {
hints: {
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
...(kv.dismissed_getting_started === undefined
? {}
: { onboarding: !kv.dismissed_getting_started }),
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
}
const readJson = Effect.fnUntraced(function* (target: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})
+5
View File
@@ -0,0 +1,5 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export type Info = Schema.Schema.Type<typeof Info>
+12 -3
View File
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -13,18 +14,26 @@ export type Input<Value> =
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (
input: Input<Node>,
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
}>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
+2
View File
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { Config } from "./config"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(Observability.layer),
+121 -298
View File
@@ -1,7 +1,7 @@
// Demo mode for testing direct interactive mode without a real SDK.
//
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
// SDK events that feed through the real reducer and footer pipeline. This
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
// presentation commits and footer actions as the live transport. This
// lets you test scrollback formatting, permission UI, question UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately.
@@ -15,10 +15,18 @@
// Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate.
import path from "path"
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
import { toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
MiniToolPart,
PermissionReply,
QuestionReject,
QuestionReply,
RunPrompt,
StreamCommit,
} from "./types"
const KINDS = [
"markdown",
@@ -124,7 +132,7 @@ type Permit = {
ref: Ref
permission: string
patterns: string[]
metadata?: Record<string, unknown>
metadata?: PermissionV2Request["metadata"]
always: string[]
done: Perm["done"]
}
@@ -132,9 +140,7 @@ type Permit = {
type State = {
id: string
thinking: boolean
data: SessionData
footer: FooterApi
limits: () => Record<string, number>
msg: number
part: number
call: number
@@ -142,12 +148,12 @@ type State = {
ask: number
perms: Map<string, Perm>
asks: Map<string, Ask>
started: Set<string>
}
type Input = {
sessionID: string
thinking: boolean
limits: () => Record<string, number>
footer: FooterApi
}
@@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
return `demo_${prefix}_${state[key]}`
}
function feed(state: State, event: Event): void {
const out = reduceSessionData({
data: state.data,
event,
sessionID: state.id,
thinking: state.thinking,
limits: state.limits(),
})
state.data = out.data
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
writeSessionOutput(
{ footer: state.footer },
{
footer: state.footer,
commits,
footer: view
? {
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
}
: undefined,
},
out,
)
}
function clearBlocker(state: State): void {
writeSessionOutput(
{ footer: state.footer },
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
)
}
function open(state: State): string {
const id = take(state, "msg", "msg")
feed(state, {
type: "message.updated",
properties: {
sessionID: state.id,
info: {
id,
sessionID: state.id,
role: "assistant",
time: {
created: Date.now(),
},
parentID: `user_${id}`,
modelID: "demo",
providerID: "demo",
mode: "demo",
agent: "demo",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0.001,
tokens: {
input: 120,
output: 320,
reasoning: 80,
cache: {
read: 0,
write: 0,
},
},
},
},
} as Event)
return id
return take(state, "msg", "msg")
}
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
let first = true
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
if (state.thinking) {
present(state, [
{
kind: "reasoning",
source: "reasoning",
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
phase: "progress",
messageID: msg,
partID: part,
},
])
first = false
}
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
@@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
}
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "running",
input: ref.input,
metadata,
time: {
start: ref.start,
},
state.started.add(ref.part)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
},
},
},
} as Event)
"start",
),
],
)
}
function askPermission(state: State, item: Permit): void {
@@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
done: item.done,
})
feed(state, {
type: "permission.asked",
properties: {
id,
sessionID: state.id,
permission: item.permission,
patterns: item.patterns,
metadata: item.metadata ?? {},
always: item.always,
tool: {
messageID: item.ref.msg,
callID: item.ref.call,
},
},
} as Event)
present(state, [], {
id,
sessionID: state.id,
action: item.permission,
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
})
}
function doneTool(
@@ -508,77 +386,53 @@ function doneTool(
metadata?: Record<string, unknown>
},
): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: {
start: ref.start,
end: Date.now(),
},
},
},
if (!state.started.has(ref.part)) startTool(state, ref)
const part: MiniToolPart = {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: { start: ref.start, end: Date.now() },
},
} as Event)
}
present(state, [toolCommit(part, output.output ? "progress" : "final")])
}
function failTool(state: State, ref: Ref, error: string): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: {
start: ref.start,
end: Date.now(),
if (!state.started.has(ref.part)) startTool(state, ref)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: { start: ref.start, end: Date.now() },
},
},
},
},
} as Event)
"final",
),
],
)
}
function emitError(state: State, text: string): void {
const event = {
id: `session.error:${state.id}:${Date.now()}`,
type: "session.error",
properties: {
sessionID: state.id,
error: {
name: "UnknownError",
data: {
message: text,
},
},
},
} satisfies Event
feed(state, event)
present(state, [{ kind: "error", source: "system", text, phase: "start" }])
}
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
@@ -685,7 +539,7 @@ function emitTask(state: State): void {
start: Date.now(),
},
},
} satisfies ToolPart
} satisfies MiniToolPart
showSubagent(state, {
sessionID: "sub_demo_1",
partID: ref.part,
@@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const id = take(state, "ask", "ask")
state.asks.set(id, { ref })
feed(state, {
type: "question.asked",
properties: {
id,
sessionID: state.id,
questions,
tool: {
messageID: ref.msg,
callID: ref.call,
},
},
} as Event)
present(state, [], {
id,
sessionID: state.id,
questions,
tool: { messageID: ref.msg, callID: ref.call },
})
}
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
@@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) {
const state: State = {
id: input.sessionID,
thinking: input.thinking,
data: createSessionData(),
footer: input.footer,
limits: input.limits,
msg: 0,
part: 0,
call: 0,
@@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
ask: 0,
perms: new Map(),
asks: new Map(),
started: new Set(),
}
const start = async (): Promise<void> => {
@@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
}
state.perms.delete(input.requestID)
const event = {
id: `permission.replied:${input.requestID}:${Date.now()}`,
type: "permission.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
reply: input.reply,
},
} satisfies Event
feed(state, event)
clearBlocker(state)
if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected")
@@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
}
state.asks.delete(input.requestID)
const event = {
id: `question.replied:${input.requestID}:${Date.now()}`,
type: "question.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
answers: input.answers,
},
} satisfies Event
feed(state, event)
clearBlocker(state)
doneTool(state, ask.ref, {
title: "question",
output: "",
@@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
}
state.asks.delete(input.requestID)
feed(state, {
type: "question.rejected",
properties: {
sessionID: state.id,
requestID: input.requestID,
},
} as Event)
clearBlocker(state)
failTool(state, ask.ref, "question rejected")
return true
}
+3 -3
View File
@@ -14,7 +14,7 @@
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import {
createPermissionBodyState,
permissionAlwaysLines,
@@ -130,7 +130,7 @@ export function RejectField(props: {
}
export function RunPermissionBody(props: {
request: PermissionRequest
request: PermissionV2Request
theme: RunFooterTheme
block: RunBlockTheme
diffStyle?: RunDiffStyle
@@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
)
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
+2 -2
View File
@@ -16,7 +16,7 @@
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Request } from "@opencode-ai/client/promise"
import {
createQuestionBodyState,
questionConfirm,
@@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: {
request: QuestionRequest
request: QuestionV2Request
theme: RunFooterTheme
onReply: (input: QuestionReply) => void | Promise<void>
onReject: (input: QuestionReject) => void | Promise<void>
-2
View File
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
this.themes.splice(index, 1)
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
public close(): void {
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
void resolveRunTheme(this.renderer).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
return
}
+10 -10
View File
@@ -1,8 +1,8 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { UI } from "./ui"
import type { MiniToolPart } from "./types"
type Model = {
providerID: string
@@ -28,8 +28,8 @@ type Input = {
auto: boolean
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean
renderTool: (part: ToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void>
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
}
type StartedPart = {
@@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
return true
}
const writeText = (part: TextPart, timestamp: number) => {
const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("text", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
@@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!promoted) continue
if (event.type === "session.step.started") {
const part: StepStartPart = {
const part = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const part: TextPart = {
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const part: ReasoningPart = {
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.step.ended") {
const part: StepFinishPart = {
const part = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
+15 -14
View File
@@ -13,7 +13,7 @@
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
@@ -55,7 +55,7 @@ function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function data(request: PermissionRequest): Dict {
function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata)
return {
...meta,
@@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
}
}
function patterns(request: PermissionRequest): string[] {
return request.patterns.filter((item): item is string => typeof item === "string")
function patterns(request: PermissionV2Request): string[] {
return request.resources.filter((item): item is string => typeof item === "string")
}
export function createPermissionBodyState(requestID: string): PermissionBodyState {
@@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return []
}
export function permissionInfo(request: PermissionRequest): PermissionInfo {
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const pats = patterns(request)
const input = data(request)
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
if (info) {
return info
}
if (request.permission === "external_directory") {
if (request.action === "external_directory") {
const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
@@ -108,7 +108,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
}
}
if (request.permission === "doom_loop") {
if (request.action === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
@@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
return {
icon: "⚙",
title: `Call tool ${request.permission}`,
lines: [`Tool: ${request.permission}`],
title: `Call tool ${request.action}`,
lines: [`Tool: ${request.action}`],
}
}
export function permissionAlwaysLines(request: PermissionRequest): string[] {
if (request.always.length === 1 && request.always[0] === "*") {
return [`This will allow ${request.permission} until OpenCode is restarted.`]
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
const save = request.save ?? []
if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
}
return [
"This will allow the following patterns until OpenCode is restarted.",
...request.always.map((item) => `- ${item}`),
...save.map((item) => `- ${item}`),
]
}
+15 -15
View File
@@ -13,7 +13,7 @@
//
// Custom answers: if a question has custom=true, an extra "Type your own
// answer" option appears. Selecting it enters editing mode with a text field.
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
import type { QuestionReject, QuestionReply } from "./types"
export type QuestionBodyState = {
@@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
return createQuestionBodyState(requestID)
}
export function questionSingle(request: QuestionRequest): boolean {
export function questionSingle(request: QuestionV2Request): boolean {
return request.questions.length === 1 && request.questions[0]?.multiple !== true
}
export function questionTabs(request: QuestionRequest): number {
export function questionTabs(request: QuestionV2Request): number {
return questionSingle(request) ? 1 : request.questions.length + 1
}
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
return !questionSingle(request) && state.tab === request.questions.length
}
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
return request.questions[state.tab]
}
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
return questionInfo(request, state)?.custom !== false
}
@@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
return state.answers[state.tab]?.includes(value) ?? false
}
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
const info = questionInfo(request, state)
if (!info || info.custom === false) {
return false
@@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
return state.selected === info.options.length
}
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
const info = questionInfo(request, state)
if (!info) {
return 0
@@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
function questionPick(
state: QuestionBodyState,
request: QuestionRequest,
request: QuestionV2Request,
answer: string,
custom = false,
): QuestionStep {
@@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
return storeAnswers(state, state.tab, list)
}
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
const total = questionTotal(request, state)
if (total === 0) {
return state
@@ -216,7 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest,
}
}
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
@@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
return questionPick(state, request, option.label)
}
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
@@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
return questionPick(state, request, value, true)
}
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
return {
requestID: request.id,
answers: questionAnswers(state, request.questions.length),
}
}
export function questionReject(request: QuestionRequest): QuestionReject {
export function questionReject(request: QuestionV2Request): QuestionReject {
return {
requestID: request.id,
}
}
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
if (state.submitting) {
return "Waiting for question event..."
}
+3 -3
View File
@@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { open } from "node:fs/promises"
import path from "node:path"
import { Server } from "../services/server"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui"
export type RunCommandInput = {
@@ -224,7 +224,7 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: ToolPart) {
async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part)
if (info.mode === "block") {
UI.empty()
@@ -240,7 +240,7 @@ async function renderTool(part: ToolPart) {
)
}
async function renderToolError(part: ToolPart) {
async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
-1
View File
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
}
+1 -5
View File
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([])
}
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
if (commit.kind === "reasoning") {
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
}
export function entrySyntax(theme: RunTheme): SyntaxStyle {
return syntax(theme.block.syntax)
}
+3 -3
View File
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
}
active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(theme)
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
? new CodeRenderable(surface.renderContext, {
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
wrapMode: "word",
drawUnstyledText: false,
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
})
: new MarkdownRenderable(surface.renderContext, {
content: "",
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
streaming: true,
internalBlockMode: "top-level",
+1 -1
View File
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
const syntax = createMemo(() => entrySyntax(theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
File diff suppressed because it is too large Load Diff
+49 -200
View File
@@ -1,15 +1,10 @@
// Session message extraction and prompt history.
//
// Fetches session messages from the SDK and extracts user turn text for
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
export type SessionMessages = SessionMessageInfo[]
type Turn = {
prompt: RunPrompt
@@ -25,133 +20,42 @@ export type RunSession = {
variant?: string
}
function fileName(url: string, filename?: string) {
if (filename) {
return filename
}
try {
const next = new URL(url)
if (next.protocol !== "file:") {
return url
}
const name = next.pathname.split("/").at(-1)
if (name) {
return decodeURIComponent(name)
}
} catch {}
return url
}
function fileSource(
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
text: { start: number; end: number; value: string },
) {
if (part.source) {
return {
...structuredClone(part.source),
text,
}
}
function messagePrompt(message: SessionMessageUser): RunPrompt {
return {
type: "file" as const,
path: part.filename ?? part.url,
text,
}
}
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
return part.type === "text" && !part.synthetic
})
.map((part) => part.text)
.join("")
let cursor = Bun.stringWidth(text)
const used: Array<{ start: number; end: number }> = []
const take = (value: string): { start: number; end: number; value: string } | undefined => {
let from = 0
while (true) {
const idx = text.indexOf(value, from)
if (idx === -1) {
return undefined
}
const start = Bun.stringWidth(text.slice(0, idx))
const end = start + Bun.stringWidth(value)
if (!used.some((item) => item.start < end && start < item.end)) {
return { start, end, value }
}
from = idx + value.length
}
}
const add = (value: string) => {
const gap = text ? " " : ""
const start = cursor + Bun.stringWidth(gap)
text += gap + value
const end = start + Bun.stringWidth(value)
cursor = end
return { start, end, value }
}
for (const part of msg.parts) {
if (part.type === "file") {
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
const span = next ?? add("@" + fileName(part.url, part.filename))
used.push({ start: span.start, end: span.end })
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: fileSource(part, span),
})
continue
}
if (part.type !== "agent") {
continue
}
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
used.push({ start: span.start, end: span.end })
parts.push({
type: "agent",
name: part.name,
source: span,
})
}
return { text, parts }
}
function turn(msg: SessionMessages[number]): Turn | undefined {
if (msg.info.role !== "user") {
return undefined
}
return {
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
source: file.mention
? {
type: "file",
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
],
}
}
export function createSession(messages: SessionMessages): RunSession {
return {
first: messages.length === 0,
turns: messages.flatMap((msg) => {
const item = turn(msg)
return item ? [item] : []
}),
turns: messages.flatMap((message) =>
message.type === "user"
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
: [],
),
}
}
@@ -164,89 +68,34 @@ export async function resolveCurrentSession(
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const messages = response.data.toReversed()
const current = createSession(response.data.toReversed())
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
if (message.type !== "user") return []
return [
{
prompt: {
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
source: file.mention
? {
type: "file" as const,
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
],
},
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
},
]
}),
...current,
turns: current.turns.map((turn) => ({
...turn,
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
})),
...(session.model && {
model: {
providerID: session.model.providerID,
modelID: session.model.id,
},
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
}),
}
}
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = []
for (const turn of session.turns) {
if (!turn.prompt.text.trim()) {
continue
}
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
continue
}
out.push(promptCopy(turn.prompt))
}
return out.slice(-limit)
return session.turns
.map((turn) => turn.prompt)
.filter((prompt) => prompt.text.trim())
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
.map(promptCopy)
.slice(-limit)
}
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
if (!model) {
return undefined
}
if (!model) return
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) return session.variant
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
return session.variant
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
continue
}
return turn.variant
}
return undefined
return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
}
+11 -7
View File
@@ -15,10 +15,14 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
@@ -32,11 +36,11 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
export function legacyTool(input: {
export function miniTool(input: {
sessionID: string
messageID: string
tool: SessionMessageAssistantTool
}): ToolPart {
}): MiniToolPart {
const tool = input.tool
const providerCall =
tool.executed === undefined && tool.providerState === undefined
@@ -109,7 +113,7 @@ export function legacyTool(input: {
}
}
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
const status = part.state.status
const text =
status === "running"
@@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({
const part = miniTool({
sessionID: child.sessionID,
messageID,
tool: item,
+15 -38
View File
@@ -1,14 +1,15 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type {
PermissionRequest,
QuestionRequest,
SessionMessageInfo,
EventSubscribeOutput,
OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2"
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
@@ -87,8 +88,6 @@ type ShellWait = {
}
type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
@@ -101,8 +100,8 @@ type ToolState = {
}
type State = {
permissions: PermissionRequest[]
questions: QuestionRequest[]
permissions: PermissionV2Request[]
questions: QuestionV2Request[]
view: FooterView
messageIDs: Set<string>
text: Map<string, string>
@@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
return "unknown error"
}
function permission(request: PermissionV2Request): PermissionRequest {
return {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool,
}
}
function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
}
@@ -229,8 +207,7 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
// Matches the commit shapes the legacy session-data reducer produced for direct
// shell calls: one "start" commit rendering `$ command` and one "progress"
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
callID: string,
@@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({
const part = miniTool({
sessionID: input.sessionID,
messageID,
tool: item,
@@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
state.permissions = permissions
state.questions = questions
syncBlockers()
await subagents.hydrate({ messages: [...projected], active })
const running = input.sessionID in active
@@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "permission.v2.asked") {
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(event.data)
syncBlockers()
return
}
@@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
syncBlockers()
return
}
+7 -7
View File
@@ -1,10 +1,10 @@
// Thin bridge between reducer output and the footer API.
// Thin bridge between transport output and the footer API.
//
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
// view + subagent state). This module forwards them to footer.append() and
// footer.event() respectively, adding trace writes along the way. It also
// defaults status updates to phase "running" if the caller didn't set a
// phase -- a convenience so reducer code doesn't have to repeat that.
// phase -- a convenience so transport code doesn't have to repeat that.
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
type Trace = {
@@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
permissions: state.permissions.map((item) => ({
id: item.id,
sessionID: item.sessionID,
permission: item.permission,
patterns: item.patterns,
tool: item.tool,
action: item.action,
resources: item.resources,
source: item.source,
metadata: item.metadata
? {
keys: Object.keys(item.metadata),
@@ -137,7 +137,7 @@ export function traceFooterOutput(footer?: FooterOutput) {
}
}
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
for (const commit of out.commits) {
input.trace?.write("ui.commit", commit)
+1 -44
View File
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
subtleSyntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
)
}
function blend(color: RGBA, bg: RGBA): RGBA {
if (color.a >= 1) {
return color
}
return RGBA.fromValues(
bg.r + (color.r - bg.r) * color.a,
bg.g + (color.g - bg.g) * color.a,
bg.b + (color.b - bg.b) * color.a,
1,
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
if (!style) {
return undefined
}
return SyntaxStyle.fromStyles(
Object.fromEntries(
[...style.getAllStyles()].map(([name, value]) => [
name,
{
...value,
fg: value.fg ? blend(value.fg, bg) : value.fg,
bg: value.bg ? blend(value.bg, bg) : value.bg,
},
]),
),
)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
@@ -502,10 +469,7 @@ function map(
scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
subtleSyntax?.destroy()
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
@@ -566,7 +530,6 @@ function map(
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
} catch {
return RUN_THEME_FALLBACK
}
+3 -4
View File
@@ -15,10 +15,9 @@
import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
output: boolean
@@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
return TOOL_RULES[name]
}
function frame(part: ToolPart): ToolFrame {
function frame(part: MiniToolPart): ToolFrame {
const state = dict(part.state)
return {
raw: "",
@@ -1231,7 +1230,7 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
)
}
export function toolInlineInfo(part: ToolPart): ToolInline {
export function toolInlineInfo(part: MiniToolPart): ToolInline {
const ctx = frame(part)
const draw = rule(ctx.name)?.run
try {
+56 -13
View File
@@ -7,12 +7,16 @@
//
// Data flow through the system:
//
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
// V2 events / demo actions → StreamCommit[] + FooterOutput
// → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type {
OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
ReferenceListOutput,
} from "@opencode-ai/client/promise"
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
export type RunFilePart = {
@@ -30,7 +34,11 @@ export type RunPromptPart =
url: string
filename?: string
mime?: string
source?: FilePart["source"]
source?: {
type: string
text: { start: number; end: number; value: string }
[key: string]: unknown
}
}
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
@@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = {
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type MiniToolState =
| { status: "pending"; input: Record<string, unknown>; raw?: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
| {
status: "completed"
input: Record<string, unknown>
output: string
title?: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
| {
status: "error"
input: Record<string, unknown>
error: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
export type MiniToolPart = {
id: string
sessionID: string
messageID: string
type?: "tool"
callID: string
tool: string
state: MiniToolState
}
export type EntryLayout = "inline" | "block"
export type RunEntryBody =
@@ -220,13 +263,13 @@ export type RunEntryBody =
| { type: "structured"; snapshot: ToolSnapshot }
// Which interactive surface the footer is showing. Only one view is active at
// a time. The reducer drives transitions: when a permission arrives the view
// a time. The transport drives transitions: when a permission arrives the view
// switches to "permission", and when the permission resolves it falls back to
// "prompt".
export type FooterView =
| { type: "prompt" }
| { type: "permission"; request: PermissionRequest }
| { type: "question"; request: QuestionRequest }
| { type: "permission"; request: PermissionV2Request }
| { type: "question"; request: QuestionV2Request }
export type FooterPromptRoute =
| { type: "composer" }
@@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
export type FooterSubagentState = {
tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail>
permissions: PermissionRequest[]
questions: QuestionRequest[]
permissions: PermissionV2Request[]
questions: QuestionV2Request[]
}
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
export type FooterOutput = {
patch?: FooterPatch
view?: FooterView
@@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The session-data reducer produces
// these from SDK events, and RunFooter.append() queues them for the next
// A single append-only commit to scrollback. The transport produces these from
// V2 events, and RunFooter.append() queues them for the next
// microtask flush. Once flushed, they become immutable terminal scrollback
// rows -- they cannot be rewritten.
export type StreamCommit = {
@@ -370,7 +413,7 @@ export type StreamCommit = {
messageID?: string
partID?: string
tool?: string
part?: ToolPart
part?: MiniToolPart
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
-24
View File
@@ -1,24 +0,0 @@
export * as TuiConfig from "./tui-config"
import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
export const load = Effect.fn("TuiConfig.load")(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filepath = path.join(global.config, "tui.json")
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
return TuiConfig.resolve(
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
{ terminalSuspend: process.platform !== "win32" },
)
})
+144
View File
@@ -0,0 +1,144 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
import { Config } from "../src/config"
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
return Effect.runPromise(
effect.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
}
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
keybinds: { leader: "ctrl+o" },
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
scroll_speed: 2,
scroll_acceleration: { enabled: true },
diff_style: "stacked",
mouse: false,
}),
)
await Bun.write(
path.join(directory, "kv.json"),
JSON.stringify({
theme_mode_lock: "light",
attention_sound_pack: "custom.pack",
diff_wrap_mode: "none",
diff_viewer_show_file_tree: false,
diff_viewer_single_patch: true,
diff_viewer_view: "split",
terminal_title_enabled: false,
file_context_enabled: false,
paste_summary_enabled: false,
sidebar: "hide",
scrollbar_visible: true,
thinking_mode: "show",
exploration_grouping: false,
tips_hidden: true,
dismissed_getting_started: true,
animations_enabled: false,
skipped_version: "9.9.9",
which_key_layout: "overlay",
}),
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
keybinds: { leader: "ctrl+o" },
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
attention: { sound_pack: "custom.pack" },
diffs: { wrap: "none", tree: false, single: true, view: "split" },
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { tips: false, onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates before the first update and does not remigrate afterward", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
yield* service.update((draft) => {
draft.animations = false
draft.mouse = false
})
yield* Effect.promise(() =>
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
)
return yield* service.get()
}),
)
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
theme: { name: "legacy" },
animations: false,
mouse: false,
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
})
}),
)
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
await Bun.$`rm -rf ${directory}`
}
})
-25
View File
@@ -1,25 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
import { TuiConfig } from "../src/tui-config"
test("loads the global tui config", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
try {
const config = await Effect.runPromise(
TuiConfig.load().pipe(
Effect.provide(Global.layerWith({ config: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+7 -6
View File
@@ -59,11 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
const compatible = yield* discover(options)
if (compatible !== undefined) return compatible
const mismatched = yield* find(options)
yield* Effect.sync(() =>
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
)
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
const existing = yield* find(options)
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
return existing.endpoint
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
@@ -138,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
}
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
@@ -161,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined
if (version !== undefined && health.value.version !== version) return undefined
return { info, endpoint } satisfies LocalService
return { info, endpoint, version: health.value.version } satisfies LocalService
}
if (
!allowLegacy ||
+39
View File
@@ -0,0 +1,39 @@
import { rename, writeFile } from "node:fs/promises"
const [registration, mode] = process.argv.slice(2)
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
let requests = 0
const server = Bun.serve({
port: 0,
async fetch(request) {
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "modern" && requests === 1) {
await writeFile(registration + ".first-request", "")
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
return new Response(null, { status: 503 })
}
if (mode === "legacy") return Response.json({ healthy: true })
return Response.json({ healthy: true, version: "test", pid: process.pid })
},
})
await writeFile(
registration + ".tmp",
JSON.stringify({
id: crypto.randomUUID(),
version: mode === "legacy" ? undefined : "test",
url: server.url.toString(),
pid: process.pid,
}),
{ mode: 0o600 },
)
await rename(registration + ".tmp", registration)
const shutdown = () => {
server.stop(true)
process.exit()
}
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)
+91
View File
@@ -0,0 +1,91 @@
import { NodeFileSystem } from "@effect/platform-node"
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service } from "../src/effect/index"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
const directories: string[] = []
afterEach(async () => {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.splice(0).map((process) => process.exited))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
spawn(registration, "modern")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: Service.StartReason[] = []
const first = run(
Service.start({
file: registration,
version: "test",
command: [],
onStart: (reason) => starts.push(reason),
}),
)
await waitForFile(registration + ".first-request")
const resolved = await run(Service.start({ file: registration, version: "test" }))
expect(resolved.url).toBe(original.url)
await writeFile(registration + ".release", "")
await first
expect(starts).toEqual([])
expect(await Bun.file(registration).json()).toEqual(original)
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
})
test("a legacy health response is still replaced", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "legacy")
await waitForFile(registration)
const starts: Service.StartReason[] = []
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
await existing.exited
})
function run<A, E>(effect: Effect.Effect<A, E, never>) {
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
}
function spawn(registration: string, mode: string, ...args: string[]) {
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
stdout: "ignore",
stderr: "inherit",
})
processes.push(subprocess)
return subprocess
}
async function temp() {
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
directories.push(directory)
return directory
}
async function waitForFile(file: string) {
for (let attempt = 0; attempt < 600; attempt++) {
if (await Bun.file(file).exists()) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${file}`)
}
async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+3 -3
View File
@@ -4,7 +4,7 @@
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
## OpenAPI
@@ -16,8 +16,8 @@
## Future Design Notes
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
+2 -2
View File
@@ -177,8 +177,8 @@ No limit has a default, on purpose: execution budgets are host policy. A host wi
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
## Boundaries and Non-Goals
-164
View File
@@ -1,164 +0,0 @@
# CodeMode Design and Status
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
completed work instead of preserving checked-off chronology.
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
[src/openapi/TODO.md](./src/openapi/TODO.md).
## How CodeMode Works
### Purpose
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
and filter or aggregate results before returning them to the agent loop.
The goals are:
- Reduce model context consumed by large tool catalogs.
- Avoid an agent round-trip between every dependent tool call.
- Keep large intermediate results inside the program instead of sending them through model context.
- Give generated code only the authority explicitly supplied by the host.
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
### Runtime
The generic runtime lives in `packages/codemode` and is host-neutral:
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
executes it without `eval`.
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
remains Effect interruption.
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
advertised as `Promise<unknown>`.
### Discovery and model workflow
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
partial.
The intended workflow is:
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
next execution.
2. Call the exact returned path without guessing or normalizing segments.
3. Narrow `Promise<unknown>` results before reading fields.
4. Start independent calls together and await them with `Promise.all`.
5. Filter and aggregate inside the program, then return only the data needed by the model.
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
path lookup, namespace browsing, deterministic ranking, and pagination.
### Tool execution
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
of work they started.
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
discarded. At most eight tool calls execute concurrently.
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
host-added framing are intentionally outside the budgets.
### Data, files, and failures
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
boundary.
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
data, tool failures, limits, timeouts, execution failures, and warning truncation.
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
attach them to the outer result, but the program receives only the structured tool output.
### V2 OpenCode adapter
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
`packages/core/src/tool/execute.ts`:
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
`Tools.Service`.
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
normally.
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
CodeMode namespaces instead of flattened model-facing names.
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
requests.
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
authorization.
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
- Nested call statuses are returned as final `execute` metadata for the TUI.
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
supervised children; the outer settlement applies Core's normal output-retention policy.
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
inside CodeMode.
## Intentionally Unsupported
These are product boundaries rather than DSL backlog:
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
supplied tools.
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
side effects. Hosts and tools own those concerns.
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
represent accurately rather than guessing semantics.
## Decisions and Rationale
| Decision | Rationale |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
## Remaining Work
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
+7 -9
View File
@@ -20,11 +20,11 @@ ultimate source of truth.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
## Values and literals
@@ -94,7 +94,7 @@ ultimate source of truth.
- [x] Optional property access and optional calls.
- [x] Function/tool calls and spread arguments.
- [x] Sequence expressions (the comma operator).
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
continuation one reaction turn.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
@@ -109,7 +109,7 @@ ultimate source of truth.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
- [x] `Promise.resolve` and `Promise.reject`.
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
@@ -148,7 +148,7 @@ ultimate source of truth.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-sandbox Object helpers.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
- [ ] `Object.groupBy`.
@@ -171,14 +171,13 @@ ultimate source of truth.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
- [x] `localeCompare`; locale and options arguments are currently ignored.
@@ -291,7 +290,6 @@ ultimate source of truth.
These are actionable implementation items. Check them off only when behavior and direct tests land.
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
`null` in render-only or OpenAPI tool calls.
+5 -6
View File
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/runtime.js"
import { executeWithLimits } from "./interpreter/execute.js"
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Definition } from "./tool.js"
@@ -9,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
/**
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
* waits for tool interruption cleanup. No default: absent means no timeout.
* Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup.
* No default: absent means no timeout.
*/
readonly timeoutMs?: number
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
readonly maxToolCalls?: number
/**
* Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate
* budget of the same size. Fixed truncation notices and host formatting are additional.
* Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget;
* truncation notices and host formatting are additional.
*/
readonly maxOutputBytes?: number
}
@@ -94,7 +94,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
export const Success = Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
@@ -0,0 +1,93 @@
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof InterpreterRuntimeError) {
return {
kind: error.kind,
message: `${error.message}${formatLocation(error.node)}`,
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
...(error.suggestions ? { suggestions: error.suggestions } : {}),
}
}
if (error instanceof ToolRuntimeError) {
return {
kind: error.kind,
message: error.message,
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
}
}
if (error instanceof ToolError) {
return { kind: "ToolFailure", message: error.message }
}
if (error instanceof ProgramThrow) {
const value = error.value
let message: string
if (containsRuntimeReference(value)) {
// Never expose runtime reference internals through thrown values.
message = "a non-data value"
} else if (typeof value === "string") {
message = value
} else if (
value !== null &&
typeof value === "object" &&
typeof (value as { message?: unknown }).message === "string"
) {
message = (value as { message: string }).message
} else {
try {
message = JSON.stringify(copyOut(value)) ?? String(value)
} catch {
message = String(value)
}
}
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
}
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
return {
kind: "ExecutionFailure",
message: "Execution exceeded the maximum nesting depth.",
}
}
if (error instanceof Error) {
return {
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
message: error.message,
}
}
return {
kind: "ExecutionFailure",
message: String(error),
}
}
export const caughtErrorValue = (thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
return createErrorValue(name, normalizeError(thrown).message)
}
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
const errors = spreadItems(args[0])
if (errors === undefined) {
throw new InterpreterRuntimeError(
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
node,
).as("TypeError")
}
// Error values must not alias caller-owned arrays.
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
}
@@ -0,0 +1,224 @@
import { parse } from "acorn"
import { Cause, Effect, Scope } from "effect"
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Interpreter } from "./runtime.js"
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<Result, never, Services<Tools>> => {
if (options.code.trim().length === 0) {
return Effect.succeed({
ok: false,
error: { kind: "ParseError", message: "Code cannot be empty." },
toolCalls: [],
})
}
// Allocate execution state inside suspension so reused Effects never share it.
return Effect.suspend(() => {
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
limits.maxToolCalls,
searchIndex,
{
onToolCallStart: options.onToolCallStart,
onToolCallEnd: options.onToolCallEnd,
},
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
// Set only after copy-out so timeouts cannot report invalid values as completed.
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
const base = Effect.acquireUseRelease(
Scope.make("parallel"),
(scope) =>
Effect.gen(function* () {
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Tools>>(scope)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
return {
ok: true,
value: result,
...(warnings.length > 0 ? { warnings } : {}),
...logged(),
toolCalls: tools.calls,
} satisfies Result
}),
(scope, exit) => Scope.close(scope, exit),
)
const timeoutMs = limits.timeoutMs
const operation =
timeoutMs === undefined
? base
: base.pipe(
Effect.timeoutOrElse({
duration: timeoutMs,
orElse: () =>
Effect.sync(() => {
if (returned === undefined) {
return {
ok: false,
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
...logged(),
toolCalls: tools.calls,
} satisfies Result
}
// Keep the timeout warning first so truncation preserves it.
return {
ok: true,
value: returned.value,
warnings: [
{
kind: "TimeoutExceeded",
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
},
...returned.promises.diagnostics(),
],
...logged(),
toolCalls: tools.calls,
} satisfies Result
}),
}),
)
return operation.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.succeed({
ok: false,
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies Result),
),
Effect.map((result) =>
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
),
)
})
}
const parseProgram = (code: string): ProgramNode => {
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
throw new InterpreterRuntimeError(
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
undefined,
"ParseError",
)
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
const parsed = parse(executableCode, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
locations: true,
}) as unknown
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
}
return parsed as ProgramNode
}
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
// Drop a replacement character produced by truncating inside a UTF-8 sequence.
const utf8Truncate = (value: string, maxBytes: number): string => {
const bytes = new TextEncoder().encode(value)
if (bytes.byteLength <= maxBytes) return value
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
}
// Warnings have a separate budget so result data cannot starve diagnostics.
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
let truncated = false
let value: DataValue = null
let valueBytes = 0
if (result.ok) {
const serialized = JSON.stringify(result.value) ?? "null"
const bytes = utf8ByteLength(serialized)
if (bytes > maxOutputBytes) {
truncated = true
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
valueBytes = maxOutputBytes
} else {
value = result.value
valueBytes = bytes
}
}
const warnings = result.ok ? (result.warnings ?? []) : []
const keptWarnings: Array<Diagnostic> = []
let warningBytes = 0
for (const warning of warnings) {
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
if (warningBytes + bytes > maxOutputBytes) break
warningBytes += bytes
keptWarnings.push(warning)
}
if (keptWarnings.length < warnings.length) {
truncated = true
keptWarnings.push({
kind: "Truncated",
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
})
}
const logs = result.logs ?? []
const kept: Array<string> = []
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
let logBytes = 0
for (const line of logs) {
const lineBytes = utf8ByteLength(line) + 1
if (logBytes + lineBytes > logBudget) break
logBytes += lineBytes
kept.push(line)
}
if (kept.length < logs.length) {
truncated = true
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
}
if (!truncated) return result
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
const logsPart = kept.length > 0 ? { logs: kept } : {}
return result.ok
? {
ok: true,
value,
...warningsPart,
...logsPart,
truncated: true,
toolCalls: result.toolCalls,
}
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
}
@@ -0,0 +1,820 @@
import { Effect } from "effect"
import {
type AstNode,
CodeModeFunction,
CoercionFunction,
GlobalMethodReference,
IntrinsicReference,
InterpreterRuntimeError,
PromiseCapabilityFunction,
supportedSyntaxMessage,
UriFunction,
} from "./model.js"
import { rejectCircularInsertion } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeJsonMethod } from "../stdlib/json.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
import { invokeObjectMethod } from "../stdlib/object.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js"
export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
export const invokeIntrinsic = <R>(
runner: CallbackRunner<R>,
ref: IntrinsicReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
) {
return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
}
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
}
if (typeof ref.receiver === "number") {
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
}
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeDate) {
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof CodeModeRegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof CodeModeMap) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeSet) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
}
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (
ref.namespace === "RegExp" ||
ref.namespace === "Map" ||
ref.namespace === "Set" ||
ref.namespace === "URLSearchParams"
) {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
}
return invokeJsonMethod(ref.name, args, node)
}
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
const str = (index: number): string => {
const arg = args[index]
if (typeof arg !== "string")
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
return arg
}
const num = (index: number): number => {
const arg = args[index]
if (typeof arg !== "number")
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
return arg
}
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
let result: unknown
switch (name) {
case "toLowerCase":
result = value.toLowerCase()
break
case "toUpperCase":
result = value.toUpperCase()
break
case "trim":
result = value.trim()
break
case "trimStart":
result = value.trimStart()
break
case "trimEnd":
result = value.trimEnd()
break
// Locale/options are deliberately unsupported; comparison uses the host default locale.
case "localeCompare":
result = value.localeCompare(str(0))
break
case "normalize": {
const form = optStr(0)
try {
result = value.normalize(form)
} catch {
throw new InterpreterRuntimeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
node,
).as("RangeError")
}
break
}
case "split": {
if (args.length === 0) {
result = [value]
break
}
if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
const requestedLimit = optNum(1)
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
break
}
case "slice":
result = value.slice(optNum(0), optNum(1))
break
case "includes":
result = value.includes(str(0), optNum(1))
break
case "startsWith":
result = value.startsWith(str(0), optNum(1))
break
case "endsWith":
result = value.endsWith(str(0), optNum(1))
break
case "indexOf":
result = value.indexOf(str(0), optNum(1))
break
case "lastIndexOf":
result = value.lastIndexOf(str(0), optNum(1))
break
case "replace":
case "replaceAll": {
if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
break
}
if (name === "replace") {
result = value.replace(str(0), str(1))
break
}
result = value.replaceAll(str(0), str(1))
break
}
case "match": {
const pattern = toHostRegex(args[0], name, node)
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return boundedData(matched, "String.match result")
return matchToValue(matched)
}
case "matchAll": {
const pattern = toHostRegex(args[0], name, node, "g")
if (!pattern.global) {
throw new InterpreterRuntimeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
node,
)
}
return Array.from(value.matchAll(pattern), matchToValue)
}
case "search": {
result = value.search(toHostRegex(args[0], name, node))
break
}
case "repeat": {
const count = num(0)
if (!Number.isFinite(count) || count < 0)
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
result = value.repeat(count)
break
}
case "padStart":
result = value.padStart(num(0), optStr(1))
break
case "padEnd":
result = value.padEnd(num(0), optStr(1))
break
case "charAt":
result = value.charAt(optNum(0) ?? 0)
break
case "at":
result = value.at(optNum(0) ?? 0)
break
case "substring":
result = value.substring(optNum(0) ?? 0, optNum(1))
break
case "charCodeAt":
result = value.charCodeAt(optNum(0) ?? 0)
break
case "codePointAt":
result = value.codePointAt(optNum(0) ?? 0)
break
case "toString":
result = value
break
case "concat": {
result = value.concat(...args.map((_, index) => str(index)))
break
}
default:
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
}
return boundedData(result, `String.${name} result`)
}
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
switch (name) {
case "isArray":
return Array.isArray(args[0])
case "of":
return [...args]
case "from": {
if (args.length > 1) {
throw new InterpreterRuntimeError(
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values())
if (args[0] instanceof CodeModeURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
const source = args[0]
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
return Array.from(source as ArrayLike<unknown>)
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
}
default:
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
}
}
const invokeStringReplacer = <R>(
runner: CallbackRunner<R>,
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
const groups = callbackArgs[callbackArgs.length - 1]
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
matches.push({ match, offset, args: callbackArgs })
return match
}
const pattern = args[0]
if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
if (typeof pattern !== "string") {
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
}
if (name === "replace") value.replace(pattern, collect)
else value.replaceAll(pattern, collect)
}
return Effect.gen(function* () {
const output: Array<string> = []
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
const resolved =
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
? yield* runner.settlePromise(replacement)
: replacement
output.push(
value.slice(end, match.offset),
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
output.push(value.slice(end))
return boundedData(output.join(""), `String.${name} result`)
})
}
export const applyCollectionCallback = <R>(
runner: CallbackRunner<R>,
callback: unknown,
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction) &&
!(callback instanceof PromiseCapabilityFunction)
) {
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
}
return (callbackArgs) =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: callback instanceof PromiseCapabilityFunction
? Effect.sync(() => callback.settle(callbackArgs[0]))
: runner.invokeFunction(callback, callbackArgs)
}
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeMap,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
switch (name) {
case "get":
return Effect.succeed(target.map.get(args[0]))
case "has":
return Effect.succeed(target.map.has(args[0]))
case "set":
return Effect.sync(() => {
target.map.set(args[0], args[1])
return target
})
case "delete":
return Effect.sync(() => target.map.delete(args[0]))
case "clear":
return Effect.sync(() => {
target.map.clear()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.map.keys()))
case "values":
return Effect.sync(() => Array.from(target.map.values()))
case "entries":
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
}
}
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeSet,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
switch (name) {
case "has":
return Effect.succeed(target.set.has(args[0]))
case "add":
return Effect.sync(() => {
target.set.add(args[0])
return target
})
case "delete":
return Effect.sync(() => target.set.delete(args[0]))
case "clear":
return Effect.sync(() => {
target.set.clear()
return undefined
})
case "keys":
case "values":
return Effect.sync(() => Array.from(target.set.values()))
case "entries":
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
}
}
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
).as("TypeError")
}
}
switch (name) {
case "append": {
requireArgs(2)
return Effect.sync(() => {
target.params.append(arg(0), arg(1))
return undefined
})
}
case "delete": {
requireArgs(1)
return Effect.sync(() => {
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
else target.params.delete(arg(0))
return undefined
})
}
case "get":
requireArgs(1)
return Effect.sync(() => target.params.get(arg(0)))
case "getAll":
requireArgs(1)
return Effect.sync(() => target.params.getAll(arg(0)))
case "has":
requireArgs(1)
return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0))))
case "set": {
requireArgs(2)
return Effect.sync(() => {
target.params.set(arg(0), arg(1))
return undefined
})
}
case "sort":
return Effect.sync(() => {
target.params.sort()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.params.keys()))
case "values":
return Effect.sync(() => Array.from(target.params.values()))
case "entries":
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
case "toString":
return Effect.sync(() => target.params.toString())
case "forEach": {
requireArgs(1)
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
}
}
const invokeArrayMethod = <R>(
runner: CallbackRunner<R>,
target: Array<unknown>,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const optNumber = (value: unknown, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number")
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
return value
}
switch (name) {
case "join": {
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
}
const input = boundedData(target, "Array.join input") as Array<unknown>
return Effect.succeed(
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
)
}
case "includes":
if (args.length === 0 || args.length > 2)
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
case "indexOf":
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
case "lastIndexOf":
return Effect.succeed(
args[1] === undefined
? target.lastIndexOf(args[0])
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
)
case "at":
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
case "slice":
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
case "concat":
return Effect.succeed(target.concat(...args))
case "flat":
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse":
return Effect.succeed(target.reverse())
case "sort":
return Effect.map(sortArray(runner, target, args[0], node), (sorted) => {
target.splice(0, target.length, ...sorted)
return target
})
case "toSorted":
return sortArray(runner, target, args[0], node)
case "toReversed":
return Effect.succeed([...target].reverse())
case "with": {
const index = optNumber(args[0], "index") ?? 0
const resolved = index < 0 ? target.length + index : index
if (resolved < 0 || resolved >= target.length) {
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
}
const copied = [...target]
copied[resolved] = args[1]
return Effect.succeed(copied)
}
case "push": {
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
target.push(...args)
return Effect.succeed(target.length)
}
case "unshift": {
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
target.unshift(...args)
return Effect.succeed(target.length)
}
case "pop":
return Effect.succeed(target.pop())
case "shift":
return Effect.succeed(target.shift())
case "splice": {
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
const start = optNumber(args[0], "start") ?? 0
if (args.length === 1) return Effect.succeed(target.splice(start))
const deleteCount = optNumber(args[1], "delete count") ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
}
case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
}
case "copyWithin":
return Effect.succeed(
target.copyWithin(
optNumber(args[0], "target index") ?? 0,
optNumber(args[1], "start") ?? 0,
optNumber(args[2], "end"),
),
)
case "keys":
return Effect.succeed(Array.from(target.keys()))
case "values":
return Effect.succeed([...target])
case "entries":
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
}
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
return Effect.gen(function* () {
// Fix iteration length while reading existing elements live.
const length = target.length
switch (name) {
case "map": {
const values: Array<unknown> = []
values.length = length
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
values[index] = yield* apply([target[index], index, target])
}
return values
}
case "flatMap": {
const values: Array<unknown> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const mapped = yield* apply([target[index], index, target])
if (Array.isArray(mapped)) values.push(...mapped)
else values.push(mapped)
}
return values
}
case "filter": {
const values: Array<unknown> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const item = target[index]
if (yield* apply([item, index, target])) values.push(item)
}
return values
}
case "find":
for (let index = 0; index < length; index += 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
}
return undefined
case "findIndex":
for (let index = 0; index < length; index += 1) {
if (yield* apply([target[index], index, target])) return index
}
return -1
case "some":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (yield* apply([target[index], index, target])) return true
}
return false
case "every":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (!(yield* apply([target[index], index, target]))) return false
}
return true
case "forEach":
for (let index = 0; index < length; index += 1) {
if (index in target) yield* apply([target[index], index, target])
}
return undefined
case "reduce": {
let start = 0
let accumulator = args[1]
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length)
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start += 1
}
for (let index = start; index < length; index += 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
}
return accumulator
}
case "reduceRight": {
let start = length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0)
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start -= 1
}
for (let index = start; index >= 0; index -= 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
}
return accumulator
}
case "findLast":
for (let index = length - 1; index >= 0; index -= 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
}
return undefined
case "findLastIndex":
for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([target[index], index, target])) return index
}
return -1
}
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
})
}
const sortArray = <R>(
runner: CallbackRunner<R>,
target: Array<unknown>,
comparator: unknown,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
}
if (!(comparator instanceof CodeModeFunction)) {
return Effect.sync(() =>
[...target].sort((a, b) => {
const left = coerceToString(a)
const right = coerceToString(b)
return left < right ? -1 : left > right ? 1 : 0
}),
)
}
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
return Effect.gen(function* () {
const left = yield* mergeSort(items.slice(0, midpoint))
const right = yield* mergeSort(items.slice(midpoint))
const merged: Array<unknown> = []
let leftIndex = 0
let rightIndex = 0
while (leftIndex < left.length && rightIndex < right.length) {
// Treat a NaN comparator result as equal to preserve stable ordering.
const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
else merged.push(right[rightIndex++])
}
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
})
}
const defined = target.filter((item) => item !== undefined)
const undefinedCount = target.length - defined.length
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
}
+3 -7
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { SandboxPromise, SandboxURL } from "../values.js"
import type { CodeModePromise, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" }
export type MemberReference = {
target: SafeObject | Array<unknown> | SandboxURL
target: SafeObject | Array<unknown> | CodeModeURL
key: string | number
}
@@ -71,13 +71,11 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: SandboxPromise,
readonly promise: CodeModePromise,
readonly name: PromiseInstanceMethodName,
) {}
}
// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes
// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS.
export class PromiseCapabilityFunction {
constructor(readonly settle: (value: unknown) => void) {}
}
@@ -114,8 +112,6 @@ export class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
// The global `search` built-in: synchronous tool discovery that shares the tool admission
// pipeline (budget, audit, hooks) without living in the `tools` tree.
export class SearchFunction {}
export class ProgramThrow {
@@ -0,0 +1,336 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import type { SafeObject } from "../tool-runtime.js"
import {
type AstNode,
CodeModeFunction,
CoercionFunction,
InterpreterRuntimeError,
ProgramThrow,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
PromiseMethodReference,
UriFunction,
} from "./model.js"
import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
import { typeofValue } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { CodeModePromise } from "../values.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<CodeModePromise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new CodeModePromise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
this.active.delete(promise)
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
this.ids.delete(promise)
return
}
const failure = normalizeError(Cause.squash(exit.cause))
this.failures.set(id, {
...failure,
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
})
})
return promise
})
})
}
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: CodeModePromise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
diagnostics(): Array<Diagnostic> {
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
}
// Re-check because a straggler can create promises before its interruption lands.
interrupt(): Effect.Effect<Array<Diagnostic>> {
const self = this
return Effect.gen(function* () {
while (self.active.size > 0) {
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
}
return self.diagnostics()
})
}
}
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
export const invokePromiseMethod = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
ref: PromiseMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (ref.name === "resolve") {
const value = args[0]
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
}
if (ref.name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
}
const spread = spreadItems(args[0])
if (spread === undefined) {
return promises.create(
Effect.fail(
new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
).as("TypeError"),
),
)
}
const items = Array.from(spread)
for (const item of items) {
if (item instanceof CodeModePromise) promises.markObserved(item)
}
switch (ref.name) {
case "all": {
const observations = items.map((item) =>
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
)
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(
settleAfterTurn(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Teardown interruption is not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
}
return outcomes
}),
),
)
}
case "race": {
if (items.length === 0) {
return promises.create(
Effect.fail(
new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
),
),
)
}
const observations = items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
}
case "any": {
const flipped = items.map((item) =>
item instanceof CodeModePromise
? Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
})
: Effect.fail(new PromiseAnyFulfilled(item)),
)
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
Effect.flatMap((reasons) =>
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
),
Effect.catch((error) =>
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
),
)
return promises.create(settleAfterTurn(body))
}
}
}
export const invokePromiseInstanceMethod = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise)
if (ref.name === "finally") {
return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node)
}
const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined
const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node)
return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node)
}
export const constructPromise = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<CodeModePromise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
node,
).as("TypeError")
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { own?: CodeModePromise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => {
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError(node))
return runner.settlePromise(value)
}),
)
box.own = promise
const resolve = new PromiseCapabilityFunction((value) => {
Deferred.doneUnsafe(deferred, Exit.succeed(value))
})
const reject = new PromiseCapabilityFunction((value) => {
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value)))
})
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return promise
})
}
// Settle one reaction turn after the deciding member, after its existing reactions.
const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
}
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
if (
value instanceof CodeModeFunction ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof PromiseCapabilityFunction
) {
return value
}
if (typeofValue(value) === "function") {
throw new InterpreterRuntimeError(
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
return undefined
}
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>(
promises: PromiseRuntime<R>,
source: CodeModePromise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* promises.await(source)
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
yield* Effect.yieldNow
return exit
})
const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
onFulfilled: ReactionHandler | undefined,
onRejected: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
const box: { derived?: CodeModePromise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
return result
})
return Effect.map(promises.create(body), (derived) => {
box.derived = derived
return derived
})
}
const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
cleanup: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
}
return yield* exit
}),
)
@@ -0,0 +1,99 @@
import {
type AstNode,
CodeModeFunction,
CoercionFunction,
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
InterpreterRuntimeError,
IntrinsicReference,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
PromiseMethodReference,
PromiseNamespace,
SearchFunction,
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
value instanceof ToolReference ||
value instanceof IntrinsicReference ||
value instanceof GlobalNamespace ||
value instanceof GlobalMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof CodeModePromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
isCodeModeValue(value)
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
if (isRuntimeReference(value)) return true
if (value === null || typeof value !== "object") return false
if (seen.has(value)) return false
seen.add(value)
const contains = Array.isArray(value)
? value.some((item) => containsRuntimeReference(item, seen))
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
seen.delete(value)
return contains
}
// CodeMode values are data here, not opaque interpreter references.
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
if (isCodeModeValue(value)) return false
if (isRuntimeReference(value)) return true
if (value === null || typeof value !== "object") return false
if (seen.has(value)) return false
seen.add(value)
const contains = Array.isArray(value)
? value.some((item) => containsOpaqueReference(item, seen))
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
seen.delete(value)
return contains
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
if (value === container)
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
seen.add(value)
const items = Array.isArray(value) ? value : Object.values(value)
for (const item of items) rejectCircularInsertion(container, item, label, node, seen)
seen.delete(value)
}
export const typeofValue = (value: unknown): string => {
if (
value instanceof CodeModeFunction ||
value instanceof CoercionFunction ||
value instanceof IntrinsicReference ||
value instanceof GlobalMethodReference ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference
)
return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
if (value instanceof GlobalNamespace) {
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
}
return typeof value
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js"
export class ScopeStack {
private readonly scopes: Array<Map<string, Binding>>
constructor(scopes: Array<Map<string, Binding>>) {
this.scopes = scopes
}
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current()
const existing = scope.get(name)
if (existing && existing.initialized !== false) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value, initialized: true })
}
get(name: string, node: AstNode): unknown {
const binding = this.resolve(name)
if (!binding) {
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
}
if (binding.initialized === false) {
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
}
return binding.value
}
set(name: string, value: unknown, node: AstNode): unknown {
const binding = this.resolve(name)
if (!binding) {
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
}
if (!binding.mutable) {
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
}
binding.value = value
return value
}
resolve(name: string): Binding | undefined {
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
const scope = this.scopes[index]
const binding = scope?.get(name)
if (binding) {
return binding
}
}
return undefined
}
current(): Map<string, Binding> {
const scope = this.scopes[this.scopes.length - 1]
if (!scope) {
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
}
return scope
}
push(scope: Map<string, Binding> = new Map()): void {
this.scopes.push(scope)
}
pop(): void {
this.scopes.pop()
}
capture(): Array<Map<string, Binding>> {
return this.scopes.slice()
}
}
+2 -4
View File
@@ -31,10 +31,8 @@ export type {
} from "./types.js"
/**
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
* operation. Auth is resolved host-side via `auth.resolve` and never
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
* operations land in `skipped`.
* Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side,
* tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`.
*/
export const fromSpec = (options: Options): Result => {
const document = options.spec
+3 -6
View File
@@ -56,7 +56,7 @@ const buildRequest = (
input: Readonly<Record<string, unknown>>,
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
Effect.gen(function* () {
// Validate every model-controlled value before auth resolution, which may refresh tokens.
// Validate model input before auth resolution can refresh credentials.
const url = buildUrl(plan, input)
if (url instanceof ToolError) return yield* Effect.fail(url)
const missing = plan.fields.find(
@@ -77,7 +77,6 @@ const buildRequest = (
request = serialized
}
// Host headers first, then declared header parameters.
request = HttpClientRequest.setHeaders(request, plan.headers)
for (const field of plan.fields) {
if (field.location !== "header") continue
@@ -169,7 +168,7 @@ const applyCredentials = (
continue
}
if (credential.type === "basic") {
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
// Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input.
const duplicate = add(
"header",
"authorization",
@@ -183,7 +182,6 @@ const applyCredentials = (
if (duplicate !== undefined) return duplicate
continue
}
// apiKey: the carrier comes from the scheme declaration.
if (definition.type !== "apiKey") {
return toolError(
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
@@ -212,8 +210,7 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
),
)
if (fieldValue instanceof ToolError) return fieldValue
// '.'/'..' survive encoding and URL normalization collapses them, letting a
// model-supplied value retarget the request to a different endpoint.
// URL normalization collapses encoded `.` and `..`, which could retarget the request.
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
return toolError(`Invalid path parameter '${field.inputName}'.`)
}
+2 -3
View File
@@ -23,8 +23,7 @@ const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value
export const nonEmptyString = (value: unknown): string | undefined =>
typeof value === "string" && value !== "" ? value : undefined
// Guards record lookups keyed by spec- or model-controlled names against
// prototype-inherited values (e.g. a parameter named `toString`).
// Spec- and model-controlled keys must not resolve inherited properties.
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
Object.hasOwn(record, key) ? record[key] : undefined
@@ -106,7 +105,7 @@ const operationParameters = (
pathItem: Record<string, unknown>,
operation: Record<string, unknown>,
): Parsed<ReadonlyArray<PlannedField>> => {
// Operation-level parameters override path-level ones sharing (location, name).
// OpenAPI operation parameters override path parameters with the same location and name.
const declared = new Map<
string,
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
+3 -9
View File
@@ -22,9 +22,8 @@ export type SecurityScheme =
| { readonly type: "openIdConnect" }
/**
* Credential material returned by a host auth resolver. The carrier for `apiKey`
* comes from the scheme definition, not the credential. `header` is the escape
* hatch for nonstandard schemes.
* Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier;
* `header` supports nonstandard schemes.
*/
export type Credential =
| { readonly type: "bearer"; readonly token: string }
@@ -33,9 +32,7 @@ export type Credential =
| { readonly type: "header"; readonly name: string; readonly value: string }
/**
* Resolves credential material for one named security scheme at call time.
* `undefined` means unavailable, try the next OR alternative; a failure aborts
* the call rather than falling through.
* Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts.
*/
export type AuthResolver = (context: {
readonly name: string
@@ -74,9 +71,7 @@ export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok
export type InputLocation = "path" | "query" | "header" | "body"
export type InputField = {
/** Model-visible field name after cross-location collision handling. */
readonly inputName: string
/** Original parameter or body-property name used on the wire. */
readonly name: string
readonly location: InputLocation
readonly required: boolean
@@ -92,7 +87,6 @@ export type OperationInput = {
readonly body: Body | undefined
}
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
export type Plan = {
+4 -4
View File
@@ -43,9 +43,9 @@ export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof SandboxSet) return Array.from(value.set.values())
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
+120 -2
View File
@@ -1,4 +1,122 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
/** Console formatting recursion ceiling; deeper values render as "...". */
export const MAX_CONSOLE_DEPTH = 32
const MAX_CONSOLE_DEPTH = 32
export const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0])
if (name === "table") return formatConsoleTable(args[0], args[1])
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}`
}
const formatConsoleArgument = (value: unknown): string => {
if (value === undefined) return "undefined"
if (typeof value === "string") return value
return formatConsoleValue(value, new Set(), 0)
}
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
if (value === null || value === undefined) return "null"
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof CodeModeMap) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}`
} finally {
seen.delete(value)
}
}
if (value instanceof CodeModeSet) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
} finally {
seen.delete(value)
}
}
if (isRuntimeReference(value)) return "[CodeMode reference]"
seen.add(value)
try {
if (Array.isArray(value)) {
return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
}
return `{${Object.entries(value)
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
.join(",")}}`
} finally {
seen.delete(value)
}
}
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
if (value === undefined) return "undefined"
if (containsOpaqueReference(value)) return "[CodeMode reference]"
const data = boundedData(value, "console.table argument")
const columns = consoleTableColumns(columnsArgument)
const rows = consoleTableRows(data, columns)
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
const header = ["(index)", ...keys].join("\t")
return [
header,
...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")),
].join("\n")
}
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
if (value === undefined) return undefined
if (containsRuntimeReference(value)) return undefined
const columns = copyOut(copyIn(value, "console.table columns"), true)
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
}
const consoleTableRows = (
data: unknown,
columns: ReadonlyArray<string> | undefined,
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))
}
return { Value: value }
}
const formatConsoleTableCell = (value: unknown): string => {
if (value === undefined) return ""
if (typeof value === "string") return value
return formatConsoleValue(value, new Set(), 0)
}
+2 -2
View File
@@ -36,7 +36,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
}
}
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNode): unknown => {
const hosted = new Date(value.time)
switch (name) {
case "getTime":
@@ -88,5 +88,5 @@ export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { SandboxDate } from "../values.js"
import { CodeModeDate } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
+9 -9
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
@@ -9,8 +9,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (isSandboxValue(input)) return {}
if (input instanceof SandboxPromise) {
if (isCodeModeValue(input)) return {}
if (input instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
@@ -46,12 +46,12 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.hasOwn(requireObject(), String(args[1]))
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) {
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
for (const source of args.slice(1)) {
if (source === null || source === undefined || isSandboxValue(source)) continue
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
@@ -60,17 +60,17 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return out
}
case "fromEntries": {
if (args[0] instanceof SandboxMap) {
if (args[0] instanceof CodeModeMap) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
return out
}
if (args[0] instanceof SandboxURLSearchParams) {
if (args[0] instanceof CodeModeURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0]
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
if (!Array.isArray(pairs)) {
boundedData(args[0], "Object.fromEntries input")
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
@@ -78,7 +78,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const out: Record<string, unknown> = Object.create(null)
for (const pair of pairs) {
const validated = boundedData(pair, "Object.fromEntries entry")
if (validated === null || typeof validated !== "object" || isSandboxValue(validated))
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
const entry = pair as Record<string, unknown>
addEntry(out, entry[0], entry[1])
-3
View File
@@ -1,6 +1,3 @@
import type { PromiseMethodName } from "../interpreter/model.js"
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
/** Maximum number of eagerly forked tool calls that may run concurrently. */
export const TOOL_CALL_CONCURRENCY = 8
+3 -3
View File
@@ -19,7 +19,7 @@ export const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
if (arg instanceof SandboxRegExp) return arg.regex
if (arg instanceof CodeModeRegExp) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
@@ -50,7 +50,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
}
export const invokeRegExpMethod = (
value: SandboxRegExp,
value: CodeModeRegExp,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -70,5 +70,5 @@ export const invokeRegExpMethod = (
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { SandboxRegExp } from "../values.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToString } from "./value.js"
-3
View File
@@ -4,12 +4,9 @@ export const stringMethods = new Set([
"trim",
"trimStart",
"trimEnd",
"trimLeft",
"trimRight",
"split",
"slice",
"substring",
"substr",
"includes",
"startsWith",
"endsWith",
+4 -4
View File
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
}
export const urlArgument = (value: unknown, label: string): string =>
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url)
return name === "canParse" ? true : new CodeModeURL(url)
} catch {
return name === "canParse" ? false : null
}
}
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { SandboxURL } from "../values.js"
import { CodeModeURL } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
+16 -16
View File
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof SandboxDate)
if (value instanceof CodeModeDate)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof CodeModeMap) return "[object Map]"
if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
@@ -50,14 +50,14 @@ export const coerceToString = (value: unknown): string => {
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof SandboxDate) return value.time
if (isSandboxValue(value)) return Number.NaN
if (value instanceof CodeModeDate) return value.time
if (isCodeModeValue(value)) return Number.NaN
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
}
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0]
if (isSandboxValue(raw)) {
if (isCodeModeValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
@@ -80,11 +80,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
+37 -141
View File
@@ -10,13 +10,13 @@ import {
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
@@ -44,36 +44,30 @@ type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] e
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
readonly name: string
}
/** Decoded tool call observed immediately before tool execution. */
export type ToolCallStarted = {
readonly index: number
readonly name: string
readonly input: unknown
}
/** Completed tool call observed immediately after tool execution settles. */
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure"
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
readonly message?: string
}
/** Non-throwing observation hooks fired around each admitted tool call. */
export type ToolCallHooks<R = never> = {
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
}
/** Model-visible description of one schema-backed tool. */
export type ToolDescription = {
readonly path: string
readonly description: string
@@ -113,11 +107,6 @@ export class ToolReference {
constructor(readonly path: ReadonlyArray<string>) {}
}
/**
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
* RangeError would.
*/
const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
@@ -152,30 +141,16 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
/**
* Validates and copies a value against the plain-data contract (depth, circularity, plain
* objects only, blocked properties, data-only leaves).
*
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
* Both modes reject un-awaited promises with an await-hinting diagnostic.
*/
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
// Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
const copyBounded = (
value: unknown,
label: string,
depth: number,
seen: Set<object>,
preserveSandboxValues: boolean,
preserveCodeModeValues: boolean,
): unknown => {
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
@@ -185,10 +160,6 @@ const copyBounded = (
value === undefined ||
typeof value === "string" ||
typeof value === "boolean" ||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
// JSON.stringify already does at any tool boundary.
typeof value === "number"
) {
return value
@@ -198,65 +169,55 @@ const copyBounded = (
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
// model exactly how to fix the program instead.
if (value instanceof SandboxPromise) {
if (value instanceof CodeModePromise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
)
}
if (preserveSandboxValues) {
// 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 (preserveCodeModeValues) {
if (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
) {
return value
}
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
if (value instanceof Date) return new SandboxDate(value.getTime())
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
if (value instanceof Date) return new CodeModeDate(value.getTime())
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new SandboxMap()
const wrapped = new CodeModeMap()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new SandboxSet()
const wrapped = new CodeModeSet()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new SandboxURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
if (value instanceof SandboxDate) {
if (value instanceof CodeModeDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof SandboxURL) return value.url.href
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
@@ -272,9 +233,9 @@ const copyBounded = (
seen.add(value)
if (Array.isArray(value)) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
if (preserveSandboxValues) {
// Array metadata is not serialized, but intra-sandbox copies must retain it.
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
if (preserveCodeModeValues) {
// Checkpoint copies retain array metadata that boundary copies omit.
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
if (isBlockedMember(key)) {
@@ -297,7 +258,7 @@ const copyBounded = (
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)
}
seen.delete(value)
return copied
@@ -305,9 +266,6 @@ const copyBounded = (
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
if (value === undefined && undefinedAsNull) return null
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
// have no JSON representation, so JSON.stringify would produce null anyway.
if (typeof value === "number" && !Number.isFinite(value)) {
return null
}
@@ -353,18 +311,10 @@ export type DiscoveryPlan = {
export type SearchEntry = {
readonly description: ToolDescription
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
readonly namespace: string
/** Lowercased path + description + input property names/descriptions, for substring matching. */
readonly searchText: string
}
/**
* Split a query into lowercased search terms. camelCase boundaries are split
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
* tokenize alike. Empties and the `*` wildcard are dropped.
*/
const tokenize = (query: string): Array<string> =>
query
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
@@ -372,13 +322,6 @@ const tokenize = (query: string): Array<string> =>
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 0 && term !== "*")
/**
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
* query term ("issues") still matches indexed text that only carries the singular
* ("issue"). Matching is one-directional substring containment, so the variants are
* needed only on the query side; scoring weights are unchanged - each field check
* passes when ANY form matches.
*/
const termForms = (term: string): Array<string> => {
const forms = [term]
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
@@ -400,8 +343,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
request.namespace === undefined
? searchIndex
: searchIndex.filter((entry) => entry.namespace === request.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 =
@@ -411,9 +352,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
(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,
// including input parameter names and descriptions (2).
const ranked =
exact !== undefined
? [exact]
@@ -451,15 +389,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
}),
})
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
// advertised signature is rendered by hand instead of through `describeDefinition`.
const searchSignature = (() => {
const definition = makeSearchTool([])
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
})()
const catalogLine = (tool: ToolDescription) => {
// Keep the tool description concise; the full schema documentation remains in the signature.
const line = tool.description.split("\n", 1)[0]!.trim()
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
@@ -479,21 +414,10 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
.toLowerCase(),
})
/** The runtime search index over every described tool. Search is always registered. */
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `catalogBudget` (estimated tokens,
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
* a namespace whose next line does not fit is done while the others keep going - so every
* namespace gets some representation before any namespace gets everything. The section
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
// Budget signatures round-robin so every namespace remains visible.
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
@@ -510,12 +434,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
}
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
// Select which signatures fit the budget before emitting, so the list can state
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
// alphabetical), every namespace still holding un-inlined tools tries to place its
// next-cheapest line against the shared budget; a namespace whose next line does not
// fit is done - the others keep going - so every namespace gets some representation
// before any namespace gets everything.
const selections = ordered.map(([namespace, group]) => ({
namespace,
picked: new Set<ToolDescription>(),
@@ -547,10 +465,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
const empty = described.length === 0
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
@@ -562,8 +476,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
// catalog already shows every signature, so step 1 picks from the list instead.
const workflow = empty
? []
: [
@@ -627,8 +539,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
for (const [namespace, group] of ordered) {
const picked = shown.get(namespace)!
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
@@ -651,13 +561,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
}
}
/**
* The enumerable names at one node of the callable tool tree - namespace names at the root,
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
* 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>): ReadonlyArray<string> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
@@ -705,18 +608,12 @@ export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/**
* The built-in `search` global: a synchronous discovery call that shares the tool
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
*/
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
maxToolCalls: number | undefined,
searchIndex: ReadonlyArray<SearchEntry>,
hooks?: ToolCallHooks<R>,
@@ -724,8 +621,7 @@ export const make = <R>(
const calls: Array<ToolCall> = []
const searchTool = makeSearchTool(searchIndex)
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
// End hooks observe settled success or failure; interruption emits neither outcome.
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
+2 -59
View File
@@ -5,13 +5,8 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> &
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
/**
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
*/
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 effectNumberSentinel = (schema: JsonSchema) =>
@@ -27,16 +22,10 @@ const intersection = (members: ReadonlyArray<string>): string => {
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
}
/**
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
*/
const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
readonly pretty: boolean
}
@@ -64,10 +53,6 @@ const hasUnresolvedRef = (
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
}
/**
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
*/
const docTags = (schema: JsonSchema): Array<string> => {
const tags: Array<string> = []
if (schema.deprecated === true) tags.push("@deprecated")
@@ -75,9 +60,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
} catch {}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
@@ -85,13 +68,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
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+$/, ""),
@@ -128,17 +105,11 @@ const renderSchema = (
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives) {
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
// real JSON Schema unions such as `string | number` or `number | null` must keep
// every branch.
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
)
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" &&
@@ -183,7 +154,6 @@ const renderSchema = (
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
}
// 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(
@@ -208,7 +178,6 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
}
}
/** Renders a raw JSON Schema document as a TypeScript type string. */
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
@@ -217,20 +186,12 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri
}
}
/** One input property of a tool, extracted best-effort from its input schema. */
export type InputProperty = {
readonly name: string
readonly description: string | undefined
readonly required: boolean
}
/**
* The property names, descriptions, and required flags of a tool's input schema - the raw
* material for search text. Best-effort: Effect Schemas go through their
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
* Anything unresolvable yields `[]` (search falls back to path + description).
*/
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
@@ -262,20 +223,11 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
}
}
/**
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
* multiline block with schema descriptions and constraints as JSDoc comments on the
* 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)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
*/
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
@@ -283,18 +235,9 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
* the host value through unchanged.
*/
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
+5 -30
View File
@@ -1,11 +1,8 @@
import { Effect, Schema } from "effect"
/**
* JSON Schema subset accepted for render-only tool schemas.
*
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
* signature only - CodeMode performs no validation against it. This is the natural shape for
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
* these schemas.
*/
export type JsonSchema = {
readonly type?: string | ReadonlyArray<string>
@@ -41,10 +38,8 @@ export type Definition<R = never> = {
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
@@ -61,29 +56,9 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
*
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
* host result is exposed as-is. The host tool remains responsible for authorization and
* durable side-effect handling.
*
* @example
* ```ts
* const lookup = Tool.make({
* description: "Look up an order",
* input: Schema.Struct({ id: Schema.String }),
* output: Schema.Struct({ status: Schema.String }),
* run: ({ id }) => Effect.succeed({ status: "open" }),
* })
*
* const fromJsonSchema = Tool.make({
* description: "Call an adapter-described tool",
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
* run: (input) => callHost(input),
* })
* ```
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
* and durable side effects.
*/
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
options: Options<I, O, R>,
+17 -17
View File
@@ -1,45 +1,45 @@
import type { Fiber } from "effect"
export class SandboxPromise {
export class CodeModePromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
}
export class SandboxDate {
export class CodeModeDate {
constructor(readonly time: number) {}
}
export class SandboxRegExp {
export class CodeModeRegExp {
readonly regex: RegExp
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
}
export class SandboxMap {
export class CodeModeMap {
readonly map = new Map<unknown, unknown>()
}
export class SandboxSet {
export class CodeModeSet {
readonly set = new Set<unknown>()
}
export class SandboxURLSearchParams {
export class CodeModeURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class SandboxURL {
readonly searchParams: SandboxURLSearchParams
export class CodeModeURL {
readonly searchParams: CodeModeURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new SandboxURLSearchParams(url.searchParams)
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
}
}
export const isSandboxValue = (
export const isCodeModeValue = (
value: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
@@ -26,9 +26,11 @@
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js
* - test/built-ins/Array/prototype/flatMap/depth-always-one.js
@@ -210,6 +212,11 @@ const cases = [
code: `let calls = 0; const result = [1].reduce(() => { calls += 1; return 2 }); return [result, calls]`,
expected: [1, 0],
},
{
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js",
code: `let accessed = false; const result = [11].reduce((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
expected: [true, true],
},
{
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js",
code: `const input = [1, 2, 3, 4, 5]; input.reduce(() => 1); return input`,
@@ -225,6 +232,11 @@ const cases = [
code: `let calls = 0; const result = [1].reduceRight(() => { calls += 1; return 2 }); return [result, calls]`,
expected: [1, 0],
},
{
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js",
code: `let accessed = false; const result = [11].reduceRight((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
expected: [true, true],
},
{
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js",
code: `const input = [1, 2, 3, 4, 5]; input.reduceRight(() => 1); return input`,
@@ -323,3 +335,46 @@ describe("Test262 Array callback adaptations", () => {
})
}
})
describe("Array callback regressions", () => {
test("reduce and reduceRight find the first present element", async () => {
expect(
await value(`
const left = []
left[2] = 3
const right = []
right[0] = 4
right[3] = 1
right.pop()
return [left.reduce((a, b) => a + b), right.reduceRight((a, b) => a + b)]
`),
).toEqual([3, 4])
})
test("reduce and reduceRight reject arrays containing only holes", async () => {
expect(
await value(`
const values = []
values[2] = 1
values.pop()
let left
let right
try { values.reduce((a, b) => a + b) } catch (error) { left = error.name }
try { values.reduceRight((a, b) => a + b) } catch (error) { right = error.name }
return [left, right]
`),
).toEqual(["TypeError", "TypeError"])
})
test("findLast returns the value observed before predicate mutation", async () => {
expect(
await value(`
const values = [1]
return values.findLast((item, index, array) => {
array[index] = 2
return true
})
`),
).toBe(1)
})
})
+18 -2
View File
@@ -26,6 +26,22 @@ describe("CodeMode host failure boundary", () => {
})
})
test("does not rewrite explicit safe tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
}),
)
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "ToolFailure",
message: "File not found: /tmp/report.json",
})
})
test("sanitizes unknown host failures and defects", async () => {
for (const failure of [
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
@@ -260,7 +276,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
})
test("renders sandbox values nested inside logged containers", async () => {
test("renders CodeMode values nested inside logged containers", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
@@ -295,7 +311,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
})
test("console.table renders sandbox value cells", async () => {
test("console.table renders CodeMode value cells", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
+12 -9
View File
@@ -8,7 +8,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
//
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
// it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
@@ -108,7 +108,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
})
test("a non-finite value becomes null when it leaves the sandbox", async () => {
test("a non-finite value becomes null when it leaves CodeMode", async () => {
expect(await value(`return 5/0`)).toBeNull()
expect(await value(`return 0/0`)).toBeNull()
expect(await value(`return Math.max()`)).toBeNull()
@@ -116,12 +116,12 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
})
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
expect(await value(`return Infinity > 1e9`)).toBe(true)
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
// JSON.stringify inside CodeMode matches JS: non-finite serializes to null
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
})
@@ -302,9 +302,12 @@ describe("CodeMode-specific string behavior", () => {
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
})
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
expect(await value(`return " x ".trimRight()`)).toBe(" x")
test("does not expose obsolete string aliases", async () => {
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
"undefined",
"undefined",
"undefined",
])
})
})
@@ -318,12 +321,12 @@ describe("compound assignment matches its binary operator", () => {
return a
}
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
test("CodeMode 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`)
expect(result).toBe("1970-01-01T00:00:01.000Z1")
})
test("sandbox Date numeric compound ops use its time value", async () => {
test("CodeMode 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)
@@ -212,7 +212,7 @@ describe("Test262 Promise statics", () => {
])
})
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
test("Promise.resolve adopts values and preserves CodeMode-promise identity", async () => {
// Sources:
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
// test/built-ins/Promise/resolve/resolve-non-obj.js
@@ -289,7 +289,7 @@ describe("Test262 Promise statics", () => {
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/allSettled/reject-immed.js
// test/built-ins/Promise/race/reject-immed.js
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
// (adapted: immediately-rejecting thenables become CodeMode promises that settled,
// and were even observed, before the combinator call)
expect(
await value(`
@@ -360,7 +360,7 @@ describe("Test262 Promise statics", () => {
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
// rejects with a catchable diagnostic instead of hanging, so this asserts CodeMode
// divergence rather than the spec never-settles behavior)
expect(
await value(`
@@ -375,7 +375,7 @@ describe("Test262 Promise statics", () => {
).toEqual([true, true])
})
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
test("Promise.resolve passes the same CodeMode promise through nested chains", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
// (adapted: no executor construction, and identity is observed with Array includes
// because promises are not comparable data values in CodeMode)
@@ -1208,7 +1208,7 @@ describe("Test262 AggregateError", () => {
test("coerces a non-string message to a string", async () => {
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the
// upstream object-with-toString case is omitted because the sandbox has no user toString dispatch)
// upstream object-with-toString case is omitted because CodeMode has no user toString dispatch)
expect(
await value(`
return [
@@ -1408,7 +1408,7 @@ describe("Test262 Promise constructor", () => {
test.failing("calling Promise without new throws TypeError", async () => {
// Source: test/built-ins/Promise/undefined-newtarget.js
// The sandbox currently reports a generic Error ("Only tools are callable in CodeMode.").
// CodeMode currently reports a generic Error ("Only tools are callable in CodeMode.").
expect(
await value(`
try {
+146 -72
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Deferred, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
@@ -17,18 +17,41 @@ type Trace = {
const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
const sleepyTool = (trace: Trace) =>
/**
* Deterministic tool set: ordering and interruption are structural, never temporal.
*
* - `echo` settles immediately with its id.
* - `gated` blocks until `open` releases the same id. Tool fibers start eagerly at the
* call site, so several gated calls are provably live at once before any `open` runs.
* - `pending` never settles; tests assert its interruption instead of racing a timer.
*
* Real clocks remain only in the wall-clock timeout tests (`timeoutMs`, `stubborn`
* cleanup), where elapsed time is the behavior under test.
*/
const echoTool = (trace: Trace) =>
Tool.make({
description: "Echo an id after a delay",
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id, ms }) =>
run: ({ id }) =>
Effect.sync(() => {
trace.starts.push(id)
trace.completed += 1
return id
}),
})
const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>) =>
Tool.make({
description: "Echo an id once its gate opens",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active)
yield* Effect.sleep(ms ?? 20)
yield* Deferred.await(gate(id))
trace.active -= 1
trace.completed += 1
return id
@@ -42,6 +65,35 @@ const sleepyTool = (trace: Trace) =>
),
})
const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
Tool.make({
description: "Open the gate for an id",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Boolean,
run: ({ id }) => Deferred.succeed(gate(id), undefined),
})
const pendingTool = (trace: Trace) =>
Tool.make({
description: "Never settle",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active)
return yield* Effect.never
}).pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
trace.active -= 1
trace.interrupted += 1
}),
),
),
})
const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
@@ -58,7 +110,7 @@ const interruptedTool = Tool.make({
const completedTool = (trace: Trace) =>
Tool.make({
description: "Return the number of completed sleepy calls",
description: "Return the number of completed calls",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(trace.completed),
@@ -88,11 +140,22 @@ const run = (
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
): Promise<CodeMode.Result> => {
const trace = options.trace ?? makeTrace()
const gates = new Map<number, Deferred.Deferred<void>>()
const gate = (id: number): Deferred.Deferred<void> => {
const existing = gates.get(id)
if (existing) return existing
const created = Deferred.makeUnsafe<void>()
gates.set(id, created)
return created
}
return Effect.runPromise(
CodeMode.execute({
tools: {
host: {
sleepy: sleepyTool(trace),
echo: echoTool(trace),
gated: gatedTool(trace, gate),
open: openTool(gate),
pending: pendingTool(trace),
fail: failingTool,
interrupt: interruptedTool,
completed: completedTool(trace),
@@ -122,7 +185,7 @@ describe("first-class promise values", () => {
expect(
await value(`
const load = async (id) => {
const result = await tools.host.sleepy({ id, ms: 20 })
const result = await tools.host.echo({ id })
return [id, result]
}
const first = load(1)
@@ -158,8 +221,10 @@ describe("first-class promise values", () => {
const trace = makeTrace()
const result = await value(
`
const a = tools.host.sleepy({ id: 1, ms: 40 })
const b = tools.host.sleepy({ id: 2, ms: 40 })
const a = tools.host.gated({ id: 1 })
const b = tools.host.gated({ id: 2 })
await tools.host.open({ id: 1 })
await tools.host.open({ id: 2 })
const rb = await b
const ra = await a
return [ra, rb]
@@ -174,7 +239,7 @@ describe("first-class promise values", () => {
test("awaiting the same promise twice settles once and never re-runs the call", async () => {
const result = await run(`
const p = tools.host.sleepy({ id: 7 })
const p = tools.host.echo({ id: 7 })
const x = await p
const y = await p
return [x, y]
@@ -182,7 +247,7 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([7, 7])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
})
test("await of a non-promise value passes it through unchanged", async () => {
@@ -193,7 +258,7 @@ describe("first-class promise values", () => {
})
test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
expect(await value(`return tools.host.echo({ id: 9 })`)).toBe(9)
})
test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
@@ -228,7 +293,7 @@ describe("first-class promise values", () => {
const trace = makeTrace()
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 30 })
tools.host.pending({ id: 1 })
return "done"
`,
{ trace },
@@ -307,7 +372,7 @@ describe("first-class promise values", () => {
const result = await run(
`
const run = async () => {
await tools.host.sleepy({ id: 1, ms: 60000 })
await tools.host.pending({ id: 1 })
tools.host.fail({})
}
run()
@@ -373,7 +438,7 @@ describe("first-class promise values", () => {
const trace = makeTrace()
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 1_000 })
tools.host.pending({ id: 1 })
throw new Error("boom")
`,
{ trace },
@@ -392,8 +457,8 @@ describe("first-class promise values", () => {
await value(
`
const launch = async () => {
tools.host.sleepy({ id: 1, ms: 60000 })
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
tools.host.pending({ id: 1 })
Promise.all([tools.host.pending({ id: 2 })])
return "returned"
}
return await launch()
@@ -411,7 +476,7 @@ describe("first-class promise values", () => {
describe("promises at data boundaries", () => {
test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
const diagnostic = await error(`return { result: tools.host.echo({ id: 1 }) }`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await tools.ns.tool(...)")
@@ -427,7 +492,7 @@ describe("promises at data boundaries", () => {
const trace = makeTrace()
const result = await run(
`
const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
const pending = tools.host.pending({ id: 1 })
return { pending }
`,
{ trace, limits: { timeoutMs: 100 } },
@@ -440,7 +505,7 @@ describe("promises at data boundaries", () => {
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
const diagnostic = await error(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
@@ -475,8 +540,10 @@ describe("Promise.all over arbitrary arrays", () => {
expect(
await value(
`
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
const first = Promise.all([tools.host.gated({ id: 1 })])
const second = Promise.all([tools.host.gated({ id: 2 })])
await tools.host.open({ id: 1 })
await tools.host.open({ id: 2 })
return [await first, await second]
`,
{ trace },
@@ -502,19 +569,19 @@ describe("Promise.all over arbitrary arrays", () => {
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
const result = await run(`
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
const aggregate = Promise.all([tools.host.echo({ id: 7 })])
return [await aggregate, await aggregate]
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([[7], [7]])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
})
test("mixes promises and plain values, preserving order", async () => {
expect(
await value(`
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
return await Promise.all([tools.host.echo({ id: 1 }), "plain", tools.host.echo({ id: 2 }), 42])
`),
).toEqual([1, "plain", 2, 42])
})
@@ -523,9 +590,9 @@ describe("Promise.all over arbitrary arrays", () => {
expect(
await value(`
const calls = []
calls.push(tools.host.sleepy({ id: 1 }))
calls.push(tools.host.echo({ id: 1 }))
calls.push(7)
const more = [tools.host.sleepy({ id: 2 })]
const more = [tools.host.echo({ id: 2 })]
const batch = [...calls, ...more, "x"]
return await Promise.all(batch)
`),
@@ -537,7 +604,9 @@ describe("Promise.all over arbitrary arrays", () => {
const result = await value(
`
const ids = [1, 2, 3, 4]
return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
const calls = ids.map((id) => tools.host.gated({ id }))
for (const id of ids) await tools.host.open({ id })
return await Promise.all(calls)
`,
{ trace },
)
@@ -552,7 +621,9 @@ describe("Promise.all over arbitrary arrays", () => {
const result = await value(
`
const ids = [1, 2, 3, 4]
return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 })))
const calls = ids.map(async (id) => await tools.host.gated({ id }))
for (const id of ids) await tools.host.open({ id })
return await Promise.all(calls)
`,
{ trace },
)
@@ -560,20 +631,21 @@ describe("Promise.all over arbitrary arrays", () => {
expect(trace.maxActive).toBeGreaterThan(1)
})
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
test("does not cap live tool-call concurrency", async () => {
const trace = makeTrace()
const result = await value(
`
const ids = []
for (let i = 0; i < 20; i += 1) ids.push(i)
const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
const calls = ids.map((id) => tools.host.gated({ id }))
for (const id of ids) await tools.host.open({ id })
const results = await Promise.all(calls)
return results.length
`,
{ trace },
)
expect(result).toBe(20)
expect(trace.maxActive).toBeGreaterThan(1)
expect(trace.maxActive).toBeLessThanOrEqual(8)
expect(trace.maxActive).toBe(20)
})
test("resolves the empty array", async () => {
@@ -583,7 +655,7 @@ describe("Promise.all over arbitrary arrays", () => {
test("rejects with the first failure, catchable in-program", async () => {
const result = await run(`
try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
await Promise.all([tools.host.echo({ id: 1 }), tools.host.fail({})])
return "no"
} catch (e) {
return e.message
@@ -602,7 +674,7 @@ describe("Promise.all over arbitrary arrays", () => {
`
try {
await Promise.all([
tools.host.sleepy({ id: 1, ms: 100 }),
tools.host.pending({ id: 1 }),
tools.host.fail({}),
])
return -1
@@ -624,11 +696,12 @@ describe("Promise.all over arbitrary arrays", () => {
expect(
await value(
`
const slow = tools.host.sleepy({ id: 1, ms: 40 })
const slow = tools.host.gated({ id: 1 })
try {
await Promise.all([slow, tools.host.fail({})])
return "no"
} catch {}
await tools.host.open({ id: 1 })
return await slow
`,
{ trace },
@@ -644,7 +717,7 @@ describe("Promise.all over arbitrary arrays", () => {
await value(
`
const failLater = async () => {
await tools.host.sleepy({ id: 1, ms: 40 })
await tools.host.pending({ id: 1 })
throw new Error("later")
}
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
@@ -669,7 +742,7 @@ describe("Promise.all over arbitrary arrays", () => {
test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
const diagnostic = await error(
`return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
`return await Promise.all([tools.host.echo({ id: 1 }), tools.host.echo({ id: 2 }), tools.host.echo({ id: 3 })])`,
{ limits: { maxToolCalls: 2 } },
)
expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
@@ -681,7 +754,7 @@ describe("Promise.allSettled", () => {
expect(
await value(`
return await Promise.allSettled([
tools.host.sleepy({ id: 5 }),
tools.host.echo({ id: 5 }),
tools.host.fail({}),
"plain",
Promise.reject(new Error("boom")),
@@ -712,8 +785,8 @@ describe("Promise.race", () => {
const trace = makeTrace()
const result = await value(
`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const fast = tools.host.echo({ id: 1 })
const slow = tools.host.pending({ id: 2 })
return await Promise.race([fast, slow])
`,
{ trace },
@@ -727,9 +800,10 @@ describe("Promise.race", () => {
test("a direct loser remains awaitable after the race settles", async () => {
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const fast = tools.host.echo({ id: 1 })
const slow = tools.host.gated({ id: 2 })
const winner = await Promise.race([fast, slow])
await tools.host.open({ id: 2 })
return { winner, loser: await slow }
`),
).toEqual({ winner: 1, loser: 2 })
@@ -741,8 +815,8 @@ describe("Promise.race", () => {
await value(
`
const nested = Promise.all([
tools.host.sleepy({ id: 1, ms: 40 }),
tools.host.sleepy({ id: 2, ms: 40 }),
tools.host.pending({ id: 1 }),
tools.host.pending({ id: 2 }),
])
return await Promise.race(["immediate", nested])
`,
@@ -758,7 +832,7 @@ describe("Promise.race", () => {
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
await Promise.race([tools.host.fail({}), tools.host.pending({ id: 1 })])
return "no"
} catch (e) {
return e.message
@@ -769,9 +843,9 @@ describe("Promise.race", () => {
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: 40 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(await value(`return await Promise.race([tools.host.pending({ id: 1 }), "immediate"])`, { trace })).toBe(
"immediate",
)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
@@ -794,7 +868,7 @@ describe("Promise.resolve / Promise.reject", () => {
test("resolve wraps plain values and passes promises through", async () => {
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
expect(await value(`return await Promise.resolve(tools.host.echo({ id: 3 }))`)).toBe(3)
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
true,
)
@@ -817,7 +891,7 @@ describe("Promise.resolve / Promise.reject", () => {
expect(
await value(`
const rejected = Promise.reject(new Error("handled"))
await tools.host.sleepy({ id: 1 })
await tools.host.echo({ id: 1 })
try {
await rejected
return "no"
@@ -847,8 +921,8 @@ describe("timeout interruption of forked calls", () => {
const trace = makeTrace()
const result = await run(
`
const a = tools.host.sleepy({ id: 1, ms: 60000 })
const b = tools.host.sleepy({ id: 2, ms: 60000 })
const a = tools.host.pending({ id: 1 })
const b = tools.host.pending({ id: 2 })
return await a
`,
{ trace, limits: { timeoutMs: 100 } },
@@ -865,7 +939,7 @@ describe("timeout interruption of forked calls", () => {
test("the timeout also interrupts calls inside Promise.all", async () => {
const trace = makeTrace()
const result = await run(
`return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
`return await Promise.all([tools.host.pending({ id: 1 }), tools.host.pending({ id: 2 })])`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
@@ -876,7 +950,7 @@ describe("timeout interruption of forked calls", () => {
test("a non-settling race loser cannot hold the execution to the timeout", async () => {
const trace = makeTrace()
const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
const result = await run(`return await Promise.race(["winner", tools.host.pending({ id: 1 })])`, {
trace,
limits: { timeoutMs: 100 },
})
@@ -941,8 +1015,8 @@ describe("promise chaining", () => {
expect(
await value(`
return await tools.host
.sleepy({ id: 2 })
.then((id) => tools.host.sleepy({ id: id + 1 }))
.echo({ id: 2 })
.then((id) => tools.host.echo({ id: id + 1 }))
.then((id) => id * 10)
`),
).toBe(30)
@@ -967,7 +1041,7 @@ describe("promise chaining", () => {
await value(`
return [
await tools.host.fail({}).catch((error) => error.message),
await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
await tools.host.echo({ id: 4 }).catch(() => "unused"),
]
`),
).toEqual(["Lookup refused", 4])
@@ -977,7 +1051,7 @@ describe("promise chaining", () => {
expect(
await value(`
const events = []
const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
const result = await tools.host.echo({ id: 5 }).finally(() => events.push("cleanup"))
return [result, events]
`),
).toEqual([5, ["cleanup"]])
@@ -1010,12 +1084,12 @@ describe("promise chaining", () => {
})
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
const diagnostic = await error(`return await tools.host.echo({ id: 1 }).then(tools.host.completed)`)
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
})
test("chaining methods are opaque references until called", async () => {
expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
expect(await value(`return typeof tools.host.echo({ id: 1 }).then`)).toBe("function")
})
})
@@ -1040,7 +1114,7 @@ describe("combinator settlement timing", () => {
// cannot beat it into rejection.
expect(
await value(`
const pending = tools.host.sleepy({ id: 9, ms: 60000 })
const pending = tools.host.pending({ id: 9 })
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
try {
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
@@ -1055,7 +1129,7 @@ describe("combinator settlement timing", () => {
describe("unsupported promise surface", () => {
test("other property reads on a promise hint at the missing await", async () => {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
const diagnostic = await error(`return tools.host.echo({ id: 1 }).value`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await it first")
@@ -1075,8 +1149,8 @@ describe("Promise.any", () => {
`
const winner = await Promise.any([
tools.host.fail({}),
tools.host.sleepy({ id: 1, ms: 5 }),
tools.host.sleepy({ id: 2, ms: 60000 }),
tools.host.echo({ id: 1 }),
tools.host.pending({ id: 2 }),
])
return winner
`,
@@ -1142,7 +1216,7 @@ describe("promise construction", () => {
const id = await gate
return id * 2
})()
openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
openGate(await tools.host.echo({ id: 21 }))
return await worker
`),
).toBe(42)
@@ -1152,7 +1226,7 @@ describe("promise construction", () => {
expect(
await value(`
const bridged = new Promise((resolve, reject) => {
tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
tools.host.echo({ id: 7 }).then(resolve, reject)
})
return await bridged
`),
@@ -1164,7 +1238,7 @@ describe("promise construction", () => {
await value(`
let settle
const manual = new Promise((resolve) => { settle = resolve })
const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
const race = Promise.race([manual, tools.host.pending({ id: 3 })])
const all = Promise.all([manual, "plain"])
const any = Promise.any([manual, new Promise(() => {})])
settle("manual")
@@ -1194,7 +1268,7 @@ describe("promise construction", () => {
expect(
await value(`
const result = new Promise(async (resolve) => {
const id = await tools.host.sleepy({ id: 5, ms: 5 })
const id = await tools.host.echo({ id: 5 })
resolve(id * 2)
})
return await result
+8 -8
View File
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
@@ -69,7 +69,7 @@ describe("Date", () => {
).toEqual([2024, 2, 5, 6, 7, 8, 9])
})
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
test("invalid dates yield NaN times, guardable in-CodeMode", async () => {
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
@@ -702,11 +702,11 @@ describe("stdlib integration", () => {
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
})
test("object spread of sandbox values is a no-op, like JS", async () => {
test("object spread of CodeMode values is a no-op, like JS", async () => {
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
})
test("dates inside Map values survive in-sandbox reads", async () => {
test("dates inside Map values survive in-CodeMode reads", async () => {
expect(
await value(`
const m = new Map([["start", new Date(1000)]])
@@ -748,7 +748,7 @@ describe("stdlib integration", () => {
})
})
describe("sandbox values at intra-sandbox checkpoints", () => {
describe("CodeMode values at intra-CodeMode 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(
@@ -799,7 +799,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
)
})
test("object and array spread keep sandbox values usable", async () => {
test("object and array spread keep CodeMode values usable", async () => {
expect(
await value(`
const src = { m: new Map([["a", 1]]) }
@@ -811,7 +811,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
})
test("Array.from over arrays keeps nested sandbox values usable", async () => {
test("Array.from over arrays keeps nested CodeMode values usable", async () => {
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
})
@@ -866,7 +866,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
})
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
test("Object.* helpers see CodeMode values as empty objects, never internals", async () => {
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
@@ -49,12 +49,6 @@
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js
* - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js
@@ -460,67 +454,6 @@ const cases = [
expected: ["this_is_"],
labels: ['#1: __string.substring(0,8) === "this_is_"'],
},
{
path: "test/annexB/built-ins/String/prototype/substr/start-negative.js",
code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`,
expected: ["c", "bc", "abc", "abc", "c"],
labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"],
},
{
path: "test/annexB/built-ins/String/prototype/substr/length-negative.js",
code: `return [
"abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4),
"abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4),
"abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4),
"abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4),
]`,
expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""],
labels: [
"0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4",
"2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4",
],
},
{
path: "test/annexB/built-ins/String/prototype/substr/length-positive.js",
code: `return [
"abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4),
"abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4),
"abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4),
"abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4),
]`,
expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""],
labels: [
"0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1",
"2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1",
],
},
{
path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js",
code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`,
expected: ["", "", "", ""],
labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"],
},
{
path: "test/annexB/built-ins/String/prototype/substr/length-undef.js",
code: `return [
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
]`,
expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""],
labels: [
"start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified",
"start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined",
],
},
{
path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js",
code: `return [
"\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2),
"\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2),
]`,
expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"],
labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"],
},
{
path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js",
code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'],
+2 -1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
"noUncheckedIndexedAccess": false,
"noUnusedLocals": true
}
}
-1
View File
@@ -200,7 +200,6 @@ const layer = Layer.effect(
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
-3
View File
@@ -52,9 +52,6 @@ export const Flag = {
get OPENCODE_EXPERIMENTAL_REFERENCES() {
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
},
get CODEMODE_ENABLED() {
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
},
get OPENCODE_TUI_CONFIG() {
return process.env["OPENCODE_TUI_CONFIG"]
},
+5 -13
View File
@@ -1,7 +1,6 @@
export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Flag } from "../flag/flag"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@@ -18,11 +17,7 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
...(Flag.CODEMODE_ENABLED
? [
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
]
: []),
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
@@ -81,7 +76,7 @@ export const layer = Layer.effect(
removed: () => "MCP server instructions are no longer available.",
},
})
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return source(Instructions.removed)
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
@@ -90,12 +85,9 @@ export const layer = Layer.effect(
const visible = instructions
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return (
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
return owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
+20 -12
View File
@@ -25,7 +25,10 @@ export interface EntryPoint {
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
@@ -47,13 +50,18 @@ export function sanitize(pkg: string) {
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: string | undefined
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {
entrypoint = undefined
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
try {
return typeof Bun !== "undefined"
? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir)
: import.meta.resolve(dir)
} catch {
return undefined
}
})
.find((entrypoint) => entrypoint !== undefined)
return {
directory: dir,
entrypoint,
@@ -112,7 +120,7 @@ const layer = Layer.effect(
}),
)
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const dir = directory(pkg)
const name = (() => {
try {
@@ -123,17 +131,17 @@ const layer = Layer.effect(
})()
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
+2 -38
View File
@@ -54,12 +54,6 @@ const PluginModule = Schema.Struct({
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
type Operation =
| {
readonly type: "add"
@@ -165,7 +159,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target)).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
@@ -194,40 +188,10 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), {
concurrency: "unbounded",
}).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} }))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
+2 -1
View File
@@ -16,6 +16,7 @@ import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
@@ -320,7 +321,7 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = input.model.route.defaults.limits?.output ?? 0
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
+2 -1
View File
@@ -543,7 +543,8 @@ const layer = Layer.effect(
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
+2 -1
View File
@@ -29,7 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
+1
View File
@@ -212,6 +212,7 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+3 -3
View File
@@ -58,16 +58,16 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
})
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
+1
View File
@@ -106,6 +106,7 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -137,6 +137,7 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+2 -2
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { Flag } from "../flag/flag"
import { MCP } from "../mcp"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard(
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
([group, record]) => tools.register(record, { group }),
{
discard: true,
},
+1
View File
@@ -191,6 +191,7 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -106,6 +106,7 @@ export const Plugin = {
}),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -139,6 +139,7 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+7 -8
View File
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent"
import { Flag } from "../flag/flag"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
@@ -56,7 +55,7 @@ const registryLayer = Layer.effect(
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly deferred: boolean
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -132,7 +131,8 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
@@ -149,7 +149,7 @@ const registryLayer = Layer.effect(
tool: entry.tool,
name: entry.name,
group: entry.group,
deferred: options?.deferred ?? false,
codemode,
},
},
])
@@ -168,18 +168,17 @@ const registryLayer = Layer.effect(
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
const direct = new Map<string, Registration>()
const deferred = new Map<string, Registration>()
const codemode = new Map<string, Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (registration.deferred) deferred.set(name, registration)
if (registration.codemode) codemode.set(name, registration)
else direct.set(name, registration)
}
const execute =
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
+3
View File
@@ -132,6 +132,8 @@ export const Plugin = {
return runtime.session.synthetic({
sessionID,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", state },
})
}),
Effect.forkIn(scope, { startImmediately: true }),
@@ -276,6 +278,7 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

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