mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b353795792 | |||
| 358d4746a9 | |||
| ce2e301e24 | |||
| fbe7f26e71 | |||
| ecb5754f4c | |||
| 36f8cb7054 | |||
| 4c45a5cf23 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 9db5073eab | |||
| 775fce0177 | |||
| 4e74f77e44 | |||
| b980f74ed9 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 | |||
| 339fd50cb9 | |||
| 828a4b3f15 | |||
| a43de3f86d | |||
| fe3b2e8f90 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 |
@@ -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
@@ -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.
|
||||
@@ -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:",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as Config from "./config"
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
)
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
})
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}'.`)
|
||||
}
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,12 +4,9 @@ export const stringMethods = new Set([
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -560,7 +560,7 @@ 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(
|
||||
`
|
||||
@@ -572,8 +572,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
{ 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 () => {
|
||||
|
||||
@@ -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,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": false
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noUnusedLocals": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -137,6 +137,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -139,6 +139,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -97,6 +97,7 @@ export const Plugin = {
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -208,6 +208,7 @@ export const Plugin = {
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user