Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline f36db6c114 refactor(core): clean up compaction flow 2026-07-09 19:18:37 -05:00
Aiden Cline fb5726baa8 refactor(core): simplify compaction recovery 2026-07-09 19:06:18 -05:00
Aiden Cline 71751ac339 fix(core): harden compaction recovery 2026-07-09 18:46:17 -05:00
Aiden Cline c6364f4b2e fix(core): restore resilient compaction 2026-07-09 18:31:42 -05:00
298 changed files with 12247 additions and 17803 deletions
-254
View File
@@ -1,254 +0,0 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+4 -5
View File
@@ -27,7 +27,6 @@ Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
@@ -157,12 +156,12 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- The durable `Instructions.Applied` record is what the model was last told, per instruction source. Reconciliation narrates drift through `session.instructions.updated` and never rewrites the baseline; only completed compaction rebaselines, while Session movement or committed revert resets the `InstructionCheckpoint`. Unavailable sources keep the model's prior belief, blocking only a Session's first instruction baseline.
+36 -36
View File
@@ -13,11 +13,11 @@ The opaque algebra of independently refreshable typed instruction sources that r
_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**.
The projected chronological conversation selected for a **Step** after applying the active compaction and **InstructionCheckpoint** baseline cutoffs.
_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.
One independently observed typed value within **Instructions**, represented by a stable namespaced key, JSON codec, loader, pure baseline/update renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**InstructionEntry**:
@@ -26,25 +26,22 @@ One API-managed, durable, per-Session instruction value. Its slash-free client k
**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.
**InstructionCheckpoint**:
The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps.
**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
A durable chronological System message published as `session.instructions.updated` that tells the model the newly effective state of one or more changed **Instruction Sources**.
_Avoid_: System notification, 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.
**Instruction Baseline**:
The exact joined instruction text stored by **InstructionCheckpoint** and sent as immutable provider-cache prefix state until completed compaction rebaselines it 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.
**Applied Instructions**:
The overwriteable model-hidden `Instructions.Applied` record in **InstructionCheckpoint**, containing what the model was last told per **Instruction Source**.
**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.
An expected temporary inability to observe an **Instruction Source** value; the runtime retains its prior effective state and emits no update, while an unavailable source blocks creation of the first complete **Instruction Baseline**.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
@@ -56,7 +53,7 @@ A durable user input accepted into the Session inbox but not yet included in **S
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.
One logical LLM call spanning pre-flight instruction checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
@@ -111,16 +108,18 @@ _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.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, the **Instruction Baseline**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** contains projected conversational messages and admitted **Instruction Updates**; the active **Instruction Baseline** remains 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.
- Each **Instruction Source** loader returns one coherent typed value or explicitly reports unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its codec compares and stores the value, while pure renderers produce baseline, update, and optional removal text.
- `Instructions.initialize(...)` observes composed **Instructions** once and produces a complete **Instruction Baseline** with **Applied Instructions**.
- `Instructions.reconcile(...)` observes composed **Instructions** once and returns either unchanged or one combined chronological update. It never rewrites the baseline.
- `Instructions.rebaseline(...)` renders a fresh baseline after completed compaction, recalling previously applied values for sources that are temporarily unavailable.
- A changed **Instruction Source** may contribute text to one **Instruction Update** containing the newly effective state.
- An **Instruction Update** persists the exact combined rendered text sent to the model through `session.instructions.updated`.
- **Applied Instructions** advances atomically with the corresponding durable **Instruction Update**.
- **Applied Instructions** stores one codec-encoded JSON value and, for removable sources, a pre-rendered removal message per stable **Instruction Source** key.
- 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.
@@ -131,27 +130,28 @@ _Avoid_: Response envelope
- 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.
- The first **Step** renders the latest complete **Instruction Baseline** and creates its **InstructionCheckpoint** without emitting a redundant **Instruction Update**; an unavailable initial source blocks the Step instead of persisting an incomplete baseline.
- 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**.
- Completed compaction rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history.
- A newly composed **Instruction Source** absent from **Applied Instructions** emits its baseline 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.
- Moving a Session resets its **InstructionCheckpoint**, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint.
- 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.
- **Instruction Updates** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- 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**.
- An **Instruction Baseline** is stored durably and reused verbatim across process restarts until rebaseline or reset.
- An **Instruction Baseline** durably preserves the exact joined text used for its part of the active provider-cache prefix.
- A model/provider switch preserves the current **InstructionCheckpoint** 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.
@@ -182,12 +182,12 @@ _Avoid_: Response envelope
- 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.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, 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.
- `sessions.events({ sessionID, after })` 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.
@@ -195,7 +195,7 @@ _Avoid_: Response envelope
- `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.
- `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 system text, **Instruction Baseline**, 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.
@@ -209,13 +209,13 @@ _Avoid_: Response envelope
- 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 truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- 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.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- 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.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- 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
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+2
View File
@@ -716,6 +716,8 @@
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
-4
View File
@@ -67,10 +67,6 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
// so a regression cannot silently burn money. Stats sync full passes scan
// ~250 GB as of 2026-07.
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
+1 -3
View File
@@ -185,9 +185,7 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
memory: "2 GB",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
@@ -74,10 +74,6 @@ test("opens and searches project files inline", async ({ page }) => {
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
@@ -90,16 +86,13 @@ test("opens and searches project files inline", async ({ page }) => {
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
@@ -109,11 +102,9 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
@@ -1,228 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}
+8 -8
View File
@@ -36,8 +36,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -1335,7 +1333,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1707,19 +1704,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButtonV2}
as={IconButton}
data-action="prompt-attach"
type="button"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item>
@@ -1,6 +1,5 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -34,10 +33,6 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
context: {
@@ -383,39 +378,6 @@ describe("prompt submit worktree selection", () => {
})
})
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,7 +191,6 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -297,10 +296,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentModel = local.model.current()
const currentAgent = local.agent.current()
const variant = modelSelection.variant.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -374,20 +372,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
}
}
if (!session) {
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContext } from "@/components/session/session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
@@ -74,6 +74,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -131,7 +132,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
test("computes usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
@@ -60,8 +60,6 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -96,4 +94,15 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,7 +21,6 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
@@ -55,7 +54,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
@@ -63,3 +61,8 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -135,6 +135,7 @@ export function SessionContextTab() {
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -203,15 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{
label: "context.stats.cacheTokens",
value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -211,17 +211,7 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onMouseDown={(e) => {
// Switch on mousedown to shave the press-release delay off tab switches.
if (e.button !== 0) return
if (store.editing) return
focus()
}}
onClick={(e) => {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (e.detail > 0) return
focus()
}}
onClick={focus}
closeButton={
<IconButton
icon="close-small"
@@ -20,11 +20,6 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-v2-panel::-webkit-scrollbar {
@@ -186,7 +181,6 @@
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
}
.settings-v2-nav-footer > span {
+2 -19
View File
@@ -1,8 +1,7 @@
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@/utils/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@@ -69,19 +68,6 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
let current = tokens[key]
for (let i = 0; i < 8 && current; i++) {
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
if (!match) {
const hex = current.trim()
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
return hex
}
current = tokens[match[1]]
}
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -252,10 +238,7 @@ export const Terminal = (props: TerminalProps) => {
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = settings.general.newLayoutDesigns()
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
fallback.background)
: (resolved["background-stronger"] ?? fallback.background)
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
+2 -10
View File
@@ -25,7 +25,6 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@@ -325,20 +324,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const activeTab = currentTab()
if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
return
}
+3 -6
View File
@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
const [saved, setSaved] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
@@ -375,12 +375,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
reset() {
setStore({ draft: undefined, promoting: undefined })
},
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
@@ -410,5 +409,3 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createPromptState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createPromptState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
})
-265
View File
@@ -1,265 +0,0 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
type PromptStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function promptStore(initial?: InitialPrompt): PromptStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => setStore("model", model),
},
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
+271 -49
View File
@@ -1,49 +1,186 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import {
createPromptReady,
createPromptSession,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
export {
createPromptReady,
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isPromptEqual,
} from "./prompt-state"
export type {
AgentPart,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
PromptScope,
PromptSession,
TextPart,
} from "./prompt-state"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
type PromptSession = ReturnType<typeof createPromptSession>
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
@@ -52,7 +189,7 @@ export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerC
)
}
function scopeKey(scope: PromptScope) {
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
@@ -62,6 +199,91 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
@@ -81,7 +303,9 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) entry.dispose()
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
}
@@ -100,11 +324,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
const load = (scope: Scope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope)
const existing = cache.get(key)
@@ -128,19 +354,15 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: PromptScope) => pick(scope).capture(),
capture: (scope?: Scope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
model: {
current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model),
},
context: {
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
@@ -150,8 +372,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
}
},
})
-3
View File
@@ -16,9 +16,6 @@ export function createTabMemory(owner: Owner | null) {
}
return {
get<T>(key: string, name: string) {
return entries.get(key)?.get(name)?.value as T | undefined
},
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
-2
View File
@@ -22,8 +22,6 @@ describe("tab memory", () => {
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
expect(memory.get("missing", "prompt")).toBeUndefined()
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
+3 -9
View File
@@ -11,7 +11,6 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
export type SessionTab = {
type: "session"
@@ -208,17 +207,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.push(tab)
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(draftHref(draftID))
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
@@ -376,9 +373,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
}
return { ...actions, store, info, ready, recentReady }
+1 -18
View File
@@ -26,13 +26,10 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -51,15 +48,12 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
useComposerCommands({ model })
useComposerCommands()
let inputRef: HTMLDivElement | undefined
@@ -67,7 +61,6 @@ export default function NewSessionPage() {
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
@@ -75,16 +68,6 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(),
})
command.register("new-session", () => [
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => inputRef?.focus(),
},
])
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
+3 -14
View File
@@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
SESSION_PANEL_WIDTH_MIN,
@@ -483,7 +483,7 @@ export default function Page() {
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
@@ -557,17 +557,6 @@ export default function Page() {
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect(
on(
() => ({ dir: sdk().directory, id: params.id }),
@@ -1278,7 +1267,7 @@ export default function Page() {
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<Show when={reviewPanelV2Rendered()}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
@@ -1,133 +0,0 @@
import { batch, createMemo, startTransition } from "solid-js"
import { useModels } from "@/context/models"
import type { ModelKey, ModelSelection } from "@/context/local"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useProviders } from "@/hooks/use-providers"
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
const sdk = useSDK()
const sync = useSync()
const models = useModels()
const prompt = usePrompt()
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const configured = () => {
const value = sync().data.config.model
if (!value) return
const [providerID, modelID] = value.split("/")
const model = { providerID, modelID }
if (valid(model)) return model
}
const recent = () => models.recent.list().find(valid)
const fallback = () => {
const defaults = providers.default()
return providers.connected().flatMap((provider) => {
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
}
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
if (!key) return
return models.find(key)
}
const recentModels = createMemo(() =>
models.recent
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
current,
recent: recentModels,
list: models.list,
cycle(direction: 1 | -1) {
const items = recentModels()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
variant: {
configured() {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
return prompt.model.current()?.variant
},
current() {
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
} satisfies ModelSelection
return selection
}
@@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
@@ -22,7 +22,6 @@ export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
const local = useLocal()
@@ -45,7 +44,7 @@ export function createPromptInputController(input: {
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({
@@ -50,102 +50,3 @@ describe("resetSessionModel", () => {
expect(calls).toEqual(["reset"])
})
})
describe("syncPromptModel", () => {
test("stores the effective session model in prompt state", () => {
const calls: unknown[] = []
syncPromptModel(
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
},
},
{
model: {
current: () => undefined,
set: (model) => calls.push(model),
},
},
)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
})
test("does not rewrite an unchanged prompt model", () => {
const calls: unknown[] = []
const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
syncPromptModel(
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
},
},
{
model: {
current: () => model,
set: (value) => calls.push(value),
},
},
)
expect(calls).toEqual([])
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
})
})
@@ -7,24 +7,6 @@ type Local = {
}
}
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
type PromptState = {
model: {
current(): { providerID: string; modelID: string; variant?: string | null } | undefined
set(model: { providerID: string; modelID: string; variant?: string | null }): void
}
}
export const resetSessionModel = (local: Local) => {
local.session.reset()
}
@@ -32,32 +14,3 @@ export const resetSessionModel = (local: Local) => {
export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg)
}
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = local.model.current()
if (!model) return
const next = {
providerID: model.provider.id,
modelID: model.id,
variant: local.model.variant.current(),
}
const current = prompt.model.current()
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
@@ -249,10 +249,8 @@ export function SessionSidePanel(props: {
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden"
class="relative min-w-0 flex overflow-hidden bg-background-base"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
@@ -271,20 +269,8 @@ export function SessionSidePanel(props: {
}}
>
<Show when={reviewOpen()}>
<div
class="relative min-w-0 h-full flex-1 overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div
class="size-full min-w-0 h-full"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
@@ -387,13 +373,7 @@ export function SessionSidePanel(props: {
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
@@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
class="relative shrink-0 overflow-hidden bg-background-stronger"
classList={{
"w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
@@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -1243,13 +1243,12 @@ export function MessageTimeline(props: {
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const tool = () => {
const asyncFile = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
if (part?.type === "tool") return part
return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool)
}
const asyncFile = () => ["edit", "write", "patch", "apply_patch"].includes(tool()?.tool ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1279,8 +1278,6 @@ export function MessageTimeline(props: {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
// Rounded virtual measurements can otherwise clip a framed row's outer paint.
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
@@ -1386,7 +1383,7 @@ export function MessageTimeline(props: {
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2.5": settings.general.newLayoutDesigns(),
"pl-2": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
@@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useLocal } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
@@ -14,7 +14,7 @@ const withCategory = (category: string) => {
})
}
export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
export const useComposerCommands = () => {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
@@ -22,7 +22,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
@@ -44,7 +43,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
})
}
@@ -62,7 +61,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => model.variant.cycle(),
onSelect: () => local.model.variant.cycle(),
}),
agentCommand({
id: "agent.cycle",
@@ -46,7 +46,6 @@ export function SessionFileBrowserTab(props: {
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
const value = query()
@@ -99,15 +98,15 @@ export function SessionFileBrowserTab(props: {
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!props.state.sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
open={props.state.sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
+1 -1
View File
@@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
}
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
-3
View File
@@ -1,4 +1 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["@opentui/solid/preload"]
@@ -18,12 +18,6 @@ export default Runtime.handler(Commands, (input) =>
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
onStart: (reason) =>
process.stderr.write(
reason === "version-mismatch"
? "Restarting background server (version mismatch)...\n"
: "Starting background server...\n",
),
})
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
+5 -13
View File
@@ -778,26 +778,19 @@ export function createPromptState(input: PromptInput): PromptState {
if (!area || area.isDestroyed) return false
const endOffset = Bun.stringWidth(area.plainText)
if (dir === -1) {
if (area.cursorOffset === 0) return false
if (area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
return
}
area.moveCursorUp()
return
if (dir === -1 && area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
}
const end =
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
? area.height - 1
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
if (area.cursorOffset === endOffset) return false
if (area.visualCursor.visualRow === end) {
if (dir === 1 && area.visualCursor.visualRow === end) {
area.cursorOffset = endOffset
return
}
area.moveCursorDown()
return false
}
const requestExit = () => {
@@ -1044,7 +1037,6 @@ export function createPromptState(input: PromptInput): PromptState {
}))
useBindings(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
+2 -3
View File
@@ -11,7 +11,6 @@ export type Args = {
readonly server?: string
readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error"
readonly onStart?: Service.StartOptions["onStart"]
}
export type Resolved = {
@@ -47,7 +46,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
}
const options = yield* ServiceConfig.options()
const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace")
const reconnectOptions = { ...options, version: undefined }
return {
endpoint,
@@ -71,7 +70,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
})
const resolveManaged = Effect.fnUntraced(function* (
options: Service.StartOptions,
options: Service.Options,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.start(options)
-106
View File
@@ -1,106 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { resolve } from "@opencode-ai/tui/config"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 0,
})
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({
tabs: [
{
sessionID: "subagent-1",
partID: "part-1",
callID: "call-1",
label: "Explore",
description: "Inspect the keymap",
status: "running",
lastUpdatedAt: 1,
},
],
details: {},
permissions: [],
questions: [],
})
const config = resolve(
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
try {
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
app.mockInput.pressArrow("down")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Select subagent")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
-1
View File
@@ -16,7 +16,6 @@
"dist"
],
"exports": {
".": "./src/promise/index.ts",
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./effect": "./src/effect/index.ts",
+3 -1
View File
@@ -550,7 +550,9 @@ export type Endpoint14_2Input = {
readonly id?: Endpoint14_2Request["payload"]["id"]
readonly title: Endpoint14_2Request["payload"]["title"]
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
readonly fields: Endpoint14_2Request["payload"]["fields"]
readonly mode: Endpoint14_2Request["payload"]["mode"]
readonly fields?: Endpoint14_2Request["payload"]["fields"]
readonly url?: Endpoint14_2Request["payload"]["url"]
}
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
+11 -2
View File
@@ -655,12 +655,21 @@ type Endpoint14_2Input = {
readonly id?: Endpoint14_2Request["payload"]["id"]
readonly title: Endpoint14_2Request["payload"]["title"]
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
readonly fields: Endpoint14_2Request["payload"]["fields"]
readonly mode: Endpoint14_2Request["payload"]["mode"]
readonly fields?: Endpoint14_2Request["payload"]["fields"]
readonly url?: Endpoint14_2Request["payload"]["url"]
}
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
payload: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
+1 -8
View File
@@ -32,12 +32,6 @@ export type Options = {
readonly command?: ReadonlyArray<string>
}
export type StartReason = "missing" | "version-mismatch"
export type StartOptions = Options & {
readonly onStart?: (reason: StartReason) => void
}
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
@@ -53,11 +47,10 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns the service command detached.
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
export const start = Effect.fn("service.start")(function* (options: Options = {}) {
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"))
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
+39 -11
View File
@@ -1,15 +1,43 @@
type Client = ReturnType<typeof import("./generated/client.js").make>
import type {
AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi,
PluginApi as EffectPluginApi,
ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi,
SkillApi as EffectSkillApi,
} from "../effect/api/api.js"
import type { Effect, Stream } from "effect"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
type PromisifyOperation<Operation> = Operation extends (
...args: infer Args
) => Effect.Effect<infer Success, unknown, unknown>
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation extends (...args: infer _Args) => unknown
? Operation
: Operation extends object
? PromisifyApi<Operation>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
}
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {
readonly provider: ProviderApi
@@ -193,12 +193,12 @@ import { ClientError } from "./client-error"
export interface ClientOptions {
readonly baseUrl: string
readonly fetch?: typeof globalThis.fetch
readonly headers?: RequestInit["headers"]
readonly headers?: HeadersInit
}
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: RequestInit["headers"]
readonly headers?: HeadersInit
}
interface RequestDescriptor {
@@ -701,7 +701,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 413, 400, 401],
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
@@ -1056,7 +1056,14 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
body: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
successStatus: 200,
declaredStatuses: [404, 409, 400, 401],
empty: false,
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
const root = await bundleInputs("@opencode-ai/client/promise", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
+20 -24
View File
@@ -130,7 +130,6 @@ type Result = Success | Failure
interface Success {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
@@ -145,7 +144,7 @@ interface Failure {
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
### Tool-call hooks
@@ -258,11 +257,11 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
The limits are exactly three knobs:
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
| Limit | Default | Bounds |
| ---------------- | -------------------: | -------------------------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
@@ -280,11 +279,9 @@ const runtime = CodeMode.make({
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
@@ -292,19 +289,18 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
Failures are data:
| Kind | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
| Kind | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
+18 -36
View File
@@ -63,30 +63,13 @@ 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.
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
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.
concurrency and data nesting depth.
### Data, files, and failures
@@ -96,7 +79,7 @@ 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.
data, tool failures, limits, timeouts, and execution failures.
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.
@@ -112,8 +95,7 @@ CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
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.
- Each nested call checks that its captured registration is still current before dispatching it.
- 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.
@@ -144,18 +126,18 @@ 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. |
| 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 tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
| 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
+15 -34
View File
@@ -92,9 +92,8 @@ 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
continuation one reaction turn.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] `await` for sandbox promises; awaiting a plain value is a no-op.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -103,41 +102,25 @@ ultimate source of truth.
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [ ] Unary `void` and `delete`.
- [ ] Arbitrary constructors.
- [ ] Arbitrary constructors and `new Promise(...)`.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once sandbox 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
promises and plain values.
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
values.
- [x] `Promise.all` preserves result order and rejects on the first observed failure.
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
combinator batches overlap as in normal JavaScript.
- [x] Promise chaining with `.then`, `.catch`, and `.finally`: handlers run deferred in attach order, returned
promises are adopted, handler throws reject the derived promise, `.finally` preserves the original settlement
unless its cleanup fails, and direct self-resolution rejects with a `TypeError`.
- [x] Every `await` (including of plain values and already-settled promises) defers its continuation one reaction
turn, so concurrent async functions interleave at await points as in JavaScript.
- [x] Combinators settle one reaction turn after their deciding member (V8-observable ordering): reactions already
attached to members run first, and an aggregate cannot beat a plain value settling in the same turn into a
`Promise.race`. Exact microtask-count parity beyond this observable ordering is not a documented guarantee.
- [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted
without a warning.
- [x] `Promise.race` interrupts losing in-flight tool calls.
- [x] Un-awaited calls are drained before execution ends; unhandled failures become diagnostics.
- [x] `try`/`catch` can handle awaited tool and promise failures.
- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection
callbacks but remain opaque references that cannot cross the data boundary.
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
- [ ] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`. These calls currently settle
before returning, so separately constructed combinator batches do not overlap as normal JavaScript promises do.
- [ ] `Promise.any`.
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
- [ ] Custom promise construction with `new Promise(...)`.
- [ ] Async iterables, host streams, and stream consumption.
## Objects and properties
@@ -168,7 +151,7 @@ ultimate source of truth.
- [ ] The mapper and `thisArg` forms of `Array.from`.
- [ ] `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.
- [ ] Complete sparse-array parity.
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
## Strings
@@ -273,8 +256,6 @@ ultimate source of truth.
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
or without `new`.
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [x] Catchable interpreter failures and awaited tool failures.
@@ -288,7 +269,7 @@ 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`.
- [ ] 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
+14 -14
View File
@@ -1,6 +1,11 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/runtime.js"
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import {
type HostTools,
type Services,
type ToolDescription,
ToolRuntime,
} from "./tool-runtime.js"
import type { Definition } from "./tool.js"
/** A tool call admitted during an execution. */
@@ -8,17 +13,11 @@ 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.
*/
/** Maximum wall-clock execution time in milliseconds. 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 of model-facing output. No default: absent means no truncation. */
readonly maxOutputBytes?: number
}
@@ -76,9 +75,8 @@ export const DiagnosticKind = Schema.Literals([
"TimeoutExceeded",
"ToolFailure",
"ExecutionFailure",
"Truncated",
])
/** Stable categories produced by program, schema, tool, limit, and truncation diagnostics. */
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind = typeof DiagnosticKind.Type
export const Diagnostic = Schema.Struct({
@@ -94,8 +92,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),
toolCalls: Schema.Array(ToolCallSchema),
@@ -125,7 +121,11 @@ export type Runtime<R = never> = {
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => {
const validateLimit = <Value extends number | undefined>(
name: keyof ExecutionLimits,
value: Value,
minimum: number,
): Value => {
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) {
throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`)
}
+4 -19
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { SandboxPromise, SandboxURL } from "../values.js"
import type { SandboxURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -61,27 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {}
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
export class PromiseMethodReference {
constructor(readonly name: PromiseMethodName) {}
}
export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: SandboxPromise,
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) {}
}
export type GlobalNamespaceName =
| "Object"
| "Math"
@@ -137,11 +122,11 @@ export type DiagnosticKind =
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName = "Error"
errorName: string = "Error"
constructor(
message: string,
File diff suppressed because it is too large Load Diff
+12 -14
View File
@@ -46,7 +46,9 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
)
}
if (json && Option.isNone(decoded)) {
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
)
}
return parsed
})
@@ -206,9 +208,8 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
return toolError(`Missing required path parameter '${field.inputName}'.`)
}
const fieldValue = serializeSimple(field, item, (value) =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
),
)
if (fieldValue instanceof ToolError) return fieldValue
@@ -270,7 +271,10 @@ const serializeQuery = (
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
return value.reduce(
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
request,
)
}
if (isRecord(value) && field.explode) {
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
@@ -285,15 +289,11 @@ const serializeQuery = (
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
}
const readResponseBody = (
response: HttpClientResponse.HttpClientResponse,
plan: Plan,
): Effect.Effect<string, ToolError> =>
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
const declaredSize =
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
@@ -304,9 +304,7 @@ const readResponseBody = (
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(
Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
)
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
body.copy(grown, 0, 0, size)
body = grown
}
+3 -7
View File
@@ -78,9 +78,7 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown
return isRecord(schema) && schema.format === "binary"
}
const jsonContent = (
content: Record<string, unknown>,
): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
}
@@ -346,7 +344,7 @@ export const operationOutput = (
if (outcomes.length === 0) return { ok: true, value: undefined }
return {
ok: true,
value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
}
}
@@ -382,9 +380,7 @@ export const operationPath = (
namespaces: ReadonlySet<string>,
): ReadonlyArray<string> => {
const raw = nonEmptyString(operation.operationId)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
sanitizeOperationSegment,
)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
if (isOperationPathAvailable(segments, used, namespaces)) return segments
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
if (conflict >= 0 && conflict + 1 < segments.length) {
+2
View File
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
"getTimezoneOffset",
])
export const dateStatics = new Set(["now", "parse", "UTC"])
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
switch (name) {
case "now":
+3
View File
@@ -6,7 +6,10 @@ import {
} from "../interpreter/model.js"
import { copyIn, copyOut } from "../tool-runtime.js"
export const jsonStatics = new Set(["stringify", "parse"])
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
switch (name) {
case "stringify": {
const replacer = args[1]
+7 -3
View File
@@ -3,9 +3,11 @@ import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
@@ -51,11 +53,13 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
const out = target as Record<string, unknown>
for (const source of args.slice(1)) {
if (source === null || source === undefined || isSandboxValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
if (source === null || source === undefined) continue
const value = source
if (isSandboxValue(value)) continue
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
}
return out
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { PromiseMethodName } from "../interpreter/model.js"
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
/** Maximum number of eagerly forked tool calls that may run concurrently. */
export const TOOL_CALL_CONCURRENCY = 8
+6 -6
View File
@@ -6,7 +6,6 @@ export const errorConstructors = new Set([
"ReferenceError",
"EvalError",
"URIError",
"AggregateError",
])
export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
@@ -21,9 +20,6 @@ export const createErrorValue = (name: string, message: string): SafeObject => {
return value
}
export const createAggregateErrorValue = (errors: Array<unknown>, message: string): SafeObject =>
Object.assign(createErrorValue("AggregateError", message), { errors })
export const errorBrandName = (value: unknown): string | undefined =>
value !== null && typeof value === "object"
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
@@ -64,7 +60,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
const value = boundedData(raw, `${ref.name} input`)
const value = boundedData(args[0], `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "parseInt") {
@@ -77,7 +73,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
}
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import {
type AstNode,
CoercionFunction,
InterpreterRuntimeError,
} from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isSandboxValue,
+12 -7
View File
@@ -326,12 +326,15 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
const definitions = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> =>
Object.entries(tools).flatMap(([name, value]) => {
): Array<{ path: string; definition: Definition<R> }> => {
const entries: Array<{ path: string; definition: Definition<R> }> = []
for (const [name, value] of Object.entries(tools)) {
const next = [...path, name]
if (isDefinition(value)) return [{ path: next.join("."), definition: value }]
return typeof value === "function" ? [] : definitions(value, next)
})
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
else if (typeof value !== "function") entries.push(...definitions(value, next))
}
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
@@ -346,6 +349,9 @@ const visibleDefinitions = <R>(tools: HostTools<R>) =>
description: describeDefinition(path, definition),
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
@@ -596,7 +602,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
@@ -611,7 +616,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
+1 -1
View File
@@ -23,7 +23,7 @@ const effectNumberSentinel = (schema: JsonSchema) =>
const intersection = (members: ReadonlyArray<string>): string => {
const concrete = members.filter((member) => member !== "unknown")
if (concrete.length === 0) return "unknown"
if (concrete.length === 1) return concrete[0]
if (concrete.length === 1) return concrete[0] ?? "unknown"
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
}
+6 -2
View File
@@ -1,7 +1,11 @@
import type { Fiber } from "effect"
import type { Effect, Fiber } from "effect"
export class SandboxPromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
interrupted = false
constructor(
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
readonly immediate?: Effect.Effect<unknown, unknown>,
) {}
}
export class SandboxDate {
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -23
View File
@@ -506,26 +506,6 @@ describe("CodeMode public contract", () => {
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
})
test("a reused execution Effect starts from a clean slate", async () => {
const echo = Tool.make({
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
code: `console.log("hi"); return await tools.host.echo({})`,
limits: { maxToolCalls: 1 },
})
const first = await Effect.runPromise(effect)
const second = await Effect.runPromise(effect)
// Per-execution state (tool-call budget and audit list, logs, timeout bookkeeping) must
// bind at run time, so the second run neither exhausts the budget nor leaks run 1's logs.
expect(first).toStrictEqual(second)
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([
@@ -672,11 +652,9 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) {
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
expect(instructions).toContain(missing)
}
expect(instructions).not.toContain("new Promise(...) are unavailable")
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")
File diff suppressed because it is too large Load Diff
+73 -748
View File
@@ -3,9 +3,8 @@ import { Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
// ordinary functions over arbitrary arrays mixing promises and plain values, and
// .then/.catch/.finally chain reactions onto any promise.
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
// ordinary functions over arbitrary arrays mixing promises and plain values.
type Trace = {
starts: Array<number>
@@ -49,13 +48,6 @@ const failingTool = Tool.make({
run: () => Effect.fail(toolError("Lookup refused")),
})
const interruptedTool = Tool.make({
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
})
const completedTool = (trace: Trace) =>
Tool.make({
description: "Return the number of completed sleepy calls",
@@ -64,25 +56,6 @@ const completedTool = (trace: Trace) =>
run: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
const stubbornTool = (trace: Trace) =>
Tool.make({
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
run: ({ cleanupMs }) =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.andThen(
Effect.sleep(cleanupMs),
Effect.sync(() => {
trace.interrupted += 1
}),
),
),
),
})
const run = (
code: string,
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
@@ -90,15 +63,7 @@ const run = (
const trace = options.trace ?? makeTrace()
return Effect.runPromise(
CodeMode.execute({
tools: {
host: {
sleepy: sleepyTool(trace),
fail: failingTool,
interrupt: interruptedTool,
completed: completedTool(trace),
stubborn: stubbornTool(trace),
},
},
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } },
code,
...(options.limits ? { limits: options.limits } : {}),
}),
@@ -185,7 +150,7 @@ describe("first-class promise values", () => {
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("await of a non-promise value passes it through unchanged", async () => {
test("await of a non-promise value is a passthrough no-op", async () => {
expect(await value(`return await 42`)).toBe(42)
expect(await value(`const x = await "s"; return x`)).toBe("s")
expect(await value(`return await null`)).toBeNull()
@@ -209,7 +174,8 @@ describe("first-class promise values", () => {
})
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
const result = await run(`
expect(
await value(`
const p = tools.host.fail({})
try {
await p
@@ -217,195 +183,57 @@ describe("first-class promise values", () => {
} catch (e) {
return e.message
}
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("Lookup refused")
expect(result.warnings).toBeUndefined()
`),
).toBe("Lookup refused")
})
test("a fire-and-forget call is interrupted when the program returns", async () => {
test("a fire-and-forget call completes before the execution ends", async () => {
const trace = makeTrace()
const result = await run(
const result = await value(
`
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
expect(result).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a never-awaited failing call preserves the result and reports the rejection", async () => {
const result = await run(`
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
const diagnostic = await error(`
tools.host.fail({})
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
])
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(diagnostic.message).toContain("Lookup refused")
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
})
test("a never-awaited failing async function is reported with a successful result", async () => {
const result = await run(`
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
const diagnostic = await error(`
const fail = async () => { throw new Error("boom") }
fail()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
expect(diagnostic.kind).toBe("ExecutionFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(diagnostic.message).toContain("boom")
})
test("output truncation bounds warning diagnostics with an in-band marker", async () => {
const result = await run(
`
for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
return "done"
`,
{ limits: { maxOutputBytes: 64 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.truncated).toBe(true)
expect(result.warnings).toStrictEqual([
{ kind: "Truncated", message: "100 additional warnings omitted by the output limit." },
])
})
test("a budget-consuming value does not starve warnings", async () => {
const result = await run(
`
Promise.reject(new Error("boom"))
return "x".repeat(500)
`,
{ limits: { maxOutputBytes: 128 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
test("an un-awaited async function's pending chain is interrupted at the return", async () => {
const trace = makeTrace()
const result = await run(
`
const run = async () => {
await tools.host.sleepy({ id: 1, ms: 60000 })
tools.host.fail({})
}
run()
return "done"
`,
{ trace },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
expect(trace.starts).toEqual([1])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("reports every unhandled rejection in promise creation order", async () => {
const result = await run(`
Promise.reject(new Error("first"))
tools.host.fail({})
Promise.reject(new Error("third"))
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
])
})
test("orders an async function rejection before promises created inside its body", async () => {
const result = await run(`
const outer = async () => {
Promise.reject(new Error("inner"))
throw new Error("outer")
test("drains promises started by an async function after an await", async () => {
const diagnostic = await error(`
const run = async () => {
await tools.host.sleepy({ id: 1 })
tools.host.fail({})
}
outer()
run()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
])
})
test("un-awaited interruptions settle without becoming rejections", async () => {
const result = await run(`
tools.host.interrupt({})
Promise.all([tools.host.interrupt({})])
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
})
test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
const trace = makeTrace()
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 1_000 })
throw new Error("boom")
`,
{ trace },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toBe("Uncaught: boom")
expect("warnings" in result).toBe(false)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("async-function promises remain owned by the execution after the function returns", async () => {
const trace = makeTrace()
expect(
await value(
`
const launch = async () => {
tools.host.sleepy({ id: 1, ms: 60000 })
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
return "returned"
}
return await launch()
`,
{ trace },
),
).toBe("returned")
// Both calls outlive launch() itself - they belong to the execution, not the function -
// and are interrupted only when the whole program returns.
expect(trace.starts).toEqual([1, 2])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(2)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Lookup refused")
})
})
@@ -423,22 +251,6 @@ describe("promises at data boundaries", () => {
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("invalid returned data cancels pending work", async () => {
const trace = makeTrace()
const result = await run(
`
const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
return { pending }
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("InvalidDataValue")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue")
@@ -458,59 +270,6 @@ describe("promises at data boundaries", () => {
})
describe("Promise.all over arbitrary arrays", () => {
test("combinators return promises that can be assigned and awaited later", async () => {
expect(
await value(`
const all = Promise.all([Promise.resolve(1)])
const settled = Promise.allSettled([Promise.reject("no")])
const race = Promise.race([Promise.resolve(2)])
const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
return [promises, await all, await settled, await race]
`),
).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
})
test("separately-created aggregate batches overlap before either is awaited", async () => {
const trace = makeTrace()
expect(
await value(
`
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
return [await first, await second]
`,
{ trace },
),
).toEqual([[1], [2]])
expect(trace.starts).toEqual([1, 2])
expect(trace.maxActive).toBeGreaterThan(1)
})
test("an aggregate created before a try block rejects at its later await", async () => {
expect(
await value(`
const aggregate = Promise.all([tools.host.fail({})])
try {
await aggregate
return "no"
} catch (error) {
return error.message
}
`),
).toBe("Lookup refused")
})
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
const result = await run(`
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
return [await aggregate, await aggregate]
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([[7], [7]])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("mixes promises and plain values, preserving order", async () => {
expect(
await value(`
@@ -581,18 +340,16 @@ describe("Promise.all over arbitrary arrays", () => {
})
test("rejects with the first failure, catchable in-program", async () => {
const result = await run(`
expect(
await value(`
try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
return "no"
} catch (e) {
return e.message
}
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("Lookup refused")
expect(result.warnings).toBeUndefined()
`),
).toBe("Lookup refused")
})
test("rejects before an earlier slow promise fulfills", async () => {
@@ -613,55 +370,10 @@ describe("Promise.all over arbitrary arrays", () => {
{ trace },
),
).toBe(0)
// The surviving member is observed (Promise.all handled it), so completion interrupts
// it instead of waiting for it.
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("fail-fast does not cancel a sibling the program still holds and awaits", async () => {
const trace = makeTrace()
expect(
await value(
`
const slow = tools.host.sleepy({ id: 1, ms: 40 })
try {
await Promise.all([slow, tools.host.fail({})])
return "no"
} catch {}
return await slow
`,
{ trace },
),
).toBe(1)
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a slower observed sibling is interrupted at completion after failing fast", async () => {
const trace = makeTrace()
expect(
await value(
`
const failLater = async () => {
await tools.host.sleepy({ id: 1, ms: 40 })
throw new Error("later")
}
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
try {
await aggregate
return "no"
} catch (error) {
return error.message
}
`,
{ trace },
),
).toBe("first")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a non-collection argument is a clear error", async () => {
const diagnostic = await error(`return await Promise.all(42)`)
expect(diagnostic.message).toContain("Promise.all expects an array")
@@ -701,64 +413,50 @@ describe("Promise.allSettled", () => {
return settled.filter((s) => s.status === "rejected").length
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe(2)
expect(result.warnings).toBeUndefined()
if (result.ok) expect(result.value).toBe(2)
})
})
describe("Promise.race", () => {
test("first settlement wins and a direct loser is interrupted at completion", async () => {
test("first settlement wins and losers are interrupted", async () => {
const trace = makeTrace()
const result = await value(
`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
return await Promise.race([fast, slow])
`,
{ trace },
)
expect(result).toBe(1)
// The loser is observed (the race handled it), so the execution does not wait for it.
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(1)
})
test("a direct loser remains awaitable after the race settles", async () => {
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 40 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow])
return { winner, loser: await slow }
try {
await slow
return "no"
} catch (e) {
return { winner, caught: e.message }
}
`),
).toEqual({ winner: 1, loser: 2 })
})
test("a nested aggregate loser and its members are interrupted at completion", async () => {
const trace = makeTrace()
expect(
await value(
`
const nested = Promise.all([
tools.host.sleepy({ id: 1, ms: 40 }),
tools.host.sleepy({ id: 2, ms: 40 }),
])
return await Promise.race(["immediate", nested])
`,
{ trace },
),
).toBe("immediate")
// The nested aggregate and its members are all observed, so nothing waits for them.
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(2)
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
})
test("a rejection can win the race", async () => {
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no"
} catch (e) {
return e.message
@@ -770,20 +468,11 @@ describe("Promise.race", () => {
test("a plain value wins over pending promises", async () => {
const trace = makeTrace()
expect(
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a rejected race loser is observed by the aggregate", async () => {
const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("winner")
expect(result.warnings).toBeUndefined()
})
test("an empty race is a clear error instead of hanging", async () => {
const diagnostic = await error(`return await Promise.race([])`)
expect(diagnostic.message).toContain("never settle")
@@ -795,9 +484,6 @@ describe("Promise.resolve / Promise.reject", () => {
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
true,
)
})
test("reject produces a promise whose await throws the reason", async () => {
@@ -812,34 +498,6 @@ describe("Promise.resolve / Promise.reject", () => {
`),
).toBe("nope")
})
test("a rejection observed after settlement is handled", async () => {
expect(
await value(`
const rejected = Promise.reject(new Error("handled"))
await tools.host.sleepy({ id: 1 })
try {
await rejected
return "no"
} catch (error) {
return error.message
}
`),
).toBe("handled")
})
test("an abandoned rejected promise is reported as unhandled", async () => {
const result = await run(`
Promise.reject(new Error("abandoned"))
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
])
})
})
describe("timeout interruption of forked calls", () => {
@@ -873,187 +531,18 @@ describe("timeout interruption of forked calls", () => {
expect(result.error.kind).toBe("TimeoutExceeded")
expect(trace.interrupted).toBe(2)
})
test("a non-settling race loser cannot hold the execution to the timeout", async () => {
const trace = makeTrace()
const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
trace,
limits: { timeoutMs: 100 },
})
// Completion interrupts the observed loser immediately; the race result survives.
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("winner")
expect(result.warnings).toBeUndefined()
expect(trace.starts).toEqual([1])
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
})
test("a timeout during completion cleanup keeps the computed value and warns", async () => {
const trace = makeTrace()
const result = await run(
`
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{
kind: "TimeoutExceeded",
message:
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
},
])
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(0)
})
test("a timeout during completion cleanup reports the timeout warning before settled rejections", async () => {
const result = await run(
`
tools.host.fail({})
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{
kind: "TimeoutExceeded",
message:
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
},
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
])
})
})
describe("promise chaining", () => {
test("then transforms tool results and adopts returned promises across a chain", async () => {
expect(
await value(`
return await tools.host
.sleepy({ id: 2 })
.then((id) => tools.host.sleepy({ id: id + 1 }))
.then((id) => id * 10)
`),
).toBe(30)
})
test("handlers are deferred and run in attach order", async () => {
expect(
await value(`
const order = []
const promise = Promise.resolve(1)
promise.then(() => order.push("h1"))
promise.then(() => order.push("h2"))
order.push("sync")
await promise
return order
`),
).toEqual(["sync", "h1", "h2"])
})
test("catch recovers a tool failure and preserves fulfillment", async () => {
expect(
await value(`
return [
await tools.host.fail({}).catch((error) => error.message),
await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
]
`),
).toEqual(["Lookup refused", 4])
})
test("finally observes settlement without changing the value", async () => {
expect(
await value(`
const events = []
const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
return [result, events]
`),
).toEqual([5, ["cleanup"]])
})
test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
const result = await run(`
Promise.reject(new Error("boom")).then((value) => value)
await Promise.resolve()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
// The source rejection belongs to the chain (no warning); only the derived tail warns.
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
test("a catch handler silences the chain's rejection warning", async () => {
const result = await run(`
Promise.reject(new Error("boom")).catch(() => "handled")
await Promise.resolve()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toBeUndefined()
})
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
})
test("chaining methods are opaque references until called", async () => {
expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
})
})
describe("combinator settlement timing", () => {
test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
// The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
// program abandons it while still pending: interrupted like any pending work, so no
// rejection warning survives - the member itself was observed by the combinator.
const result = await run(`
Promise.all([Promise.reject(new Error("boom"))])
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
})
test("a combinator settles one reaction turn after its members, as in V8", async () => {
// Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
// so a plain resolved value entered in the same race wins, and a fail-fast aggregate
// cannot beat it into rejection.
expect(
await value(`
const pending = tools.host.sleepy({ id: 9, ms: 60000 })
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
try {
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
return [winner, "fulfilled", raced]
} catch (reason) {
return [winner, "rejected", reason]
}
`),
).toEqual([2, "fulfilled", "ok"])
})
})
describe("unsupported promise surface", () => {
test(".then/.catch/.finally give a clear await-instead error", async () => {
for (const method of ["then", "catch", "finally"]) {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
expect(diagnostic.message).toContain("await")
}
})
test("other property reads on a promise hint at the missing await", async () => {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
expect(diagnostic.kind).toBe("InvalidDataValue")
@@ -1062,179 +551,15 @@ describe("unsupported promise surface", () => {
})
test("unknown Promise statics list what is available", async () => {
const diagnostic = await error(`return await Promise.withResolvers()`)
expect(diagnostic.message).toContain("Promise.withResolvers is not available")
expect(diagnostic.message).toContain("Promise.any")
})
})
describe("Promise.any", () => {
test("first tool success wins; failing and losing calls are handled silently", async () => {
const trace = makeTrace()
const result = await run(
`
const winner = await Promise.any([
tools.host.fail({}),
tools.host.sleepy({ id: 1, ms: 5 }),
tools.host.sleepy({ id: 2, ms: 60000 }),
])
return winner
`,
{ trace },
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe(1)
// The slow loser stays execution-owned and is interrupted at completion; the tool
// failure was observed by the aggregate, so no rejection warning survives.
expect(result.warnings).toBeUndefined()
expect(trace.interrupted).toBe(1)
})
test("all members failing rejects with catch-normalized reasons in input order", async () => {
expect(
await value(`
try {
await Promise.any([tools.host.fail({}), Promise.reject("plain")])
return "fulfilled"
} catch (error) {
return [error.name, error.errors.map((reason) => reason.message ?? reason)]
}
`),
).toEqual(["AggregateError", ["Lookup refused", "plain"]])
})
test("settles one reaction turn after its deciding member, as in V8", async () => {
expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2)
})
test("a tie is decided by settlement order, not input order", async () => {
// Handlers run in attach order, so `first` settles before `second` and wins
// despite its later input position - as in real JS.
expect(
await value(`
const first = Promise.resolve().then(() => "one")
const second = Promise.resolve().then(() => "two")
return await Promise.any([second, first])
`),
).toBe("one")
})
test("an abandoned rejecting aggregate is interrupted silently at the return", async () => {
const result = await run(`
Promise.any([Promise.reject(new Error("boom"))])
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
})
})
describe("promise construction", () => {
test("a deferred gate coordinates tool results across async functions", async () => {
expect(
await value(`
let openGate
const gate = new Promise((resolve) => { openGate = resolve })
const worker = (async () => {
const id = await gate
return id * 2
})()
openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
return await worker
`),
).toBe(42)
})
test("the .then(resolve) bridge settles a constructed promise", async () => {
expect(
await value(`
const bridged = new Promise((resolve, reject) => {
tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
})
return await bridged
`),
).toBe(7)
})
test("constructed promises participate in combinators", async () => {
expect(
await value(`
let settle
const manual = new Promise((resolve) => { settle = resolve })
const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
const all = Promise.all([manual, "plain"])
const any = Promise.any([manual, new Promise(() => {})])
settle("manual")
return [await race, await all, await any]
`),
).toEqual(["manual", ["manual", "plain"], "manual"])
})
test("resolving with a pending promise adopts its later settlement", async () => {
expect(
await value(`
let innerResolve, innerReject
const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve })))
const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject })))
innerResolve("later")
innerReject("bad")
try {
return [await adopted, await adoptedRejection]
} catch (reason) {
return [await adopted, reason]
}
`),
).toEqual(["later", "bad"])
})
test("an async executor's post-await resolve settles the promise", async () => {
expect(
await value(`
const result = new Promise(async (resolve) => {
const id = await tools.host.sleepy({ id: 5, ms: 5 })
resolve(id * 2)
})
return await result
`),
).toBe(10)
})
test("a never-settled promise is abandoned silently at the return", async () => {
const result = await run(`
const forever = new Promise(() => {})
forever.then(() => {})
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toBeUndefined()
})
test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => {
const result = await run(`
new Promise((_, reject) => reject(new Error("dropped")))
await Promise.resolve()
await Promise.resolve()
return "done"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toHaveLength(1)
expect(result.warnings?.[0].message).toContain("Unhandled rejection")
expect(result.warnings?.[0].message).toContain("dropped")
})
test("resolver functions cannot cross the data boundary", async () => {
const diagnostic = await error(`
let escaped
new Promise((resolve) => { escaped = resolve })
return { escaped }
`)
expect(diagnostic.kind).toBe("InvalidDataValue")
const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`)
expect(diagnostic.message).toContain("Promise.any is not available")
expect(diagnostic.message).toContain("Promise.allSettled")
})
test("new Promise(...) points at tool calls instead", async () => {
const diagnostic = await error(`return new Promise((resolve) => resolve(1))`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain("new Promise(...) is not supported")
expect(diagnostic.message).toContain("already return promises")
})
})
+77
View File
@@ -0,0 +1,77 @@
# Test262 Array Coverage
The Array tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 35
exposed instance methods and three static methods using actual arrays, accepted argument types, deterministic behavior,
and CodeMode's materialized collection conventions. Each executable case names its exact upstream source path.
`LICENSE.test262` contains the upstream BSD terms.
This is coverage of CodeMode's bounded Array surface, not a claim of ECMAScript or Test262 conformance. One upstream
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
were adapted.
## Inventory
The 38 relevant upstream API directories contain 2,837 files. The executable suite adapts assertions from 83 distinct
sources.
| API | Upstream files | Adapted sources |
| ------------------------------- | -------------: | --------------: |
| `Array.prototype.map` | 216 | 3 |
| `Array.prototype.filter` | 242 | 3 |
| `Array.prototype.find` | 23 | 4 |
| `Array.prototype.findIndex` | 23 | 3 |
| `Array.prototype.findLast` | 24 | 3 |
| `Array.prototype.findLastIndex` | 24 | 3 |
| `Array.prototype.some` | 219 | 2 |
| `Array.prototype.every` | 218 | 2 |
| `Array.prototype.includes` | 30 | 2 |
| `Array.prototype.join` | 23 | 2 |
| `Array.prototype.reduce` | 260 | 3 |
| `Array.prototype.reduceRight` | 260 | 3 |
| `Array.prototype.flatMap` | 24 | 2 |
| `Array.prototype.forEach` | 190 | 2 |
| `Array.prototype.sort` | 54 | 3 |
| `Array.prototype.toSorted` | 21 | 4 |
| `Array.prototype.slice` | 71 | 1 |
| `Array.prototype.concat` | 69 | 3 |
| `Array.prototype.indexOf` | 201 | 2 |
| `Array.prototype.lastIndexOf` | 198 | 2 |
| `Array.prototype.at` | 13 | 3 |
| `Array.prototype.flat` | 19 | 2 |
| `Array.prototype.reverse` | 18 | 1 |
| `Array.prototype.toReversed` | 17 | 2 |
| `Array.prototype.with` | 21 | 2 |
| `Array.prototype.push` | 24 | 1 |
| `Array.prototype.pop` | 23 | 1 |
| `Array.prototype.shift` | 20 | 1 |
| `Array.prototype.unshift` | 22 | 1 |
| `Array.prototype.splice` | 81 | 3 |
| `Array.prototype.fill` | 22 | 3 |
| `Array.prototype.copyWithin` | 39 | 2 |
| `Array.prototype.keys` | 12 | 1 |
| `Array.prototype.values` | 12 | 1 |
| `Array.prototype.entries` | 12 | 1 |
| `Array.from` | 47 | 3 |
| `Array.isArray` | 29 | 2 |
| `Array.of` | 16 | 1 |
## Exclusions
Assertions are not adapted when they test behavior outside CodeMode's documented Array surface:
- Function metadata, property descriptors, constructibility, prototype mutation, species constructors, or cross-realm
identity.
- Generic receivers, detached methods, `.call`, `.apply`, boxed values, custom coercion objects, Symbols, BigInts,
proxies, accessors, frozen arrays, typed arrays, or ArrayBuffers.
- `Array.from` mappers, custom iterables, constructor substitution, and iterator-closing behavior.
- Native iterator identity, `.next()`, completion records, or live iterator mutation. CodeMode deliberately materializes
`keys`, `values`, and `entries` as arrays.
- Sparse-array assertions that depend on literal elisions or inherited indexed properties. CodeMode's confined data
model does not preserve those prototype and hole semantics at every boundary.
- Argument coercions outside the accepted schema-like surface. Numeric positions must be numbers and `join` separators
must be strings.
- Exact native error brands where CodeMode exposes a safe runtime error instead.
- Async/effectful callbacks, circular-data rejection, sandbox-value identity, diagnostics, and host-boundary behavior.
Those remain covered by CodeMode-specific tests.
Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript Array semantics.
+69
View File
@@ -0,0 +1,69 @@
# Test262 String Coverage
The String tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 32
exposed instance methods and two static methods using primitive receivers, accepted argument types, and deterministic
behavior. Each executable case names its exact upstream source path. `LICENSE.test262` contains the upstream BSD terms.
This is coverage of CodeMode's bounded String surface, not a claim of ECMAScript or Test262 conformance. One upstream
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
were adapted.
## Inventory
The relevant upstream directories contain 1,048 files: 1,009 core built-in files, 29 Annex B files for exposed methods,
and 10 Intl `localeCompare` files. The executable suite adapts assertions from 298 distinct sources.
| API | Upstream files | Adapted sources |
| --- | ---: | ---: |
| `String.fromCharCode` | 17 | 6 |
| `String.fromCodePoint` | 11 | 4 |
| `String.prototype.at` | 11 | 5 |
| `String.prototype.charAt` | 30 | 9 |
| `String.prototype.charCodeAt` | 25 | 4 |
| `String.prototype.codePointAt` | 16 | 6 |
| `String.prototype.concat` | 22 | 1 |
| `String.prototype.endsWith` | 27 | 13 |
| `String.prototype.includes` | 27 | 12 |
| `String.prototype.indexOf` | 47 | 8 |
| `String.prototype.lastIndexOf` | 25 | 1 |
| `String.prototype.localeCompare` | 23 | 1 |
| `String.prototype.match` | 52 | 9 |
| `String.prototype.matchAll` | 26 | 1 |
| `String.prototype.normalize` | 14 | 3 |
| `String.prototype.padEnd` | 13 | 4 |
| `String.prototype.padStart` | 13 | 4 |
| `String.prototype.repeat` | 16 | 4 |
| `String.prototype.replace` | 56 | 16 |
| `String.prototype.replaceAll` | 46 | 12 |
| `String.prototype.search` | 44 | 10 |
| `String.prototype.slice` | 38 | 11 |
| `String.prototype.split` | 121 | 50 |
| `String.prototype.startsWith` | 21 | 7 |
| `String.prototype.substr` | 15 | 6 |
| `String.prototype.substring` | 46 | 12 |
| `String.prototype.toLowerCase` | 30 | 5 |
| `String.prototype.toString` | 7 | 1 |
| `String.prototype.toUpperCase` | 26 | 3 |
| `String.prototype.trim` | 129 | 66 |
| `String.prototype.trimEnd` | 23 | 2 |
| `String.prototype.trimLeft` | 4 | 0 |
| `String.prototype.trimRight` | 4 | 0 |
| `String.prototype.trimStart` | 23 | 2 |
## Exclusions
Assertions are not adapted when they test behavior outside CodeMode's documented String surface:
- Function metadata, property descriptors, constructibility, prototype mutation, or cross-realm identity.
- The `trimLeft`/`trimRight` Test262 files assert prototype function identity, which CodeMode does not expose. Their
supported call behavior remains covered by CodeMode-specific tests.
- Boxed strings, generic receivers, custom coercion objects, Symbols, BigInts, or argument types CodeMode rejects.
- Symbol-based RegExp dispatch, custom matchers, species constructors, or iterator protocol details. CodeMode materializes
`matchAll` results instead of exposing iterators.
- Locale selection and options. CodeMode deliberately uses the host default locale and ignores those arguments.
- Test262 harness behavior or setup syntax unavailable in the confined interpreter.
- Function-replacer behavior that is covered by CodeMode-specific tests for sequential callbacks, async tool calls,
result coercion, diagnostics, and sandbox boundaries.
- Assertions requiring an exact native error type when CodeMode deliberately exposes only its safe runtime error.
Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript String semantics.
+1 -4
View File
@@ -3,7 +3,4 @@ Allow: /
# Disallow shared content pages
Disallow: /s/
Disallow: /share/
Sitemap: https://opencode.ai/sitemap.xml
Sitemap: https://opencode.ai/data/sitemap.xml
Disallow: /share/
@@ -4,7 +4,6 @@ type Usage = {
input_tokens?: number
input_tokens_details?: {
cached_tokens?: number
cache_write_tokens?: number
}
output_tokens?: number
output_tokens_details?: {
@@ -49,13 +48,12 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: cacheWriteTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
@@ -65,20 +65,4 @@ describe("provider usage extraction", () => {
output_tokens: 7,
})
})
test("parses OpenAI stream cache write usage", () => {
const usageParser = providers.openai.createUsageParser()
usageParser.parse(
'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}',
)
expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({
inputTokens: 6,
outputTokens: 2,
reasoningTokens: undefined,
cacheReadTokens: 4,
cacheWrite5mTokens: 3,
cacheWrite1hTokens: undefined,
})
})
})
+87 -245
View File
@@ -1,10 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed",
"prevIds": [
"666138ef-82cb-4a9a-a765-e6669a436ff3"
],
"id": "2c759c08-79c8-4179-9e15-d2fea45c9dec",
"prevIds": ["01451b27-1e51-4657-b2d0-4b457dffa3ec"],
"ddl": [
{
"name": "workspace",
@@ -51,17 +49,13 @@
"entityType": "tables"
},
{
"name": "instruction_blob",
"name": "instruction_checkpoint",
"entityType": "tables"
},
{
"name": "instruction_entry",
"entityType": "tables"
},
{
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
@@ -792,19 +786,39 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "hash",
"name": "session_id",
"entityType": "columns",
"table": "instruction_blob"
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"name": "baseline",
"entityType": "columns",
"table": "instruction_blob"
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "snapshot",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline_seq",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "text",
@@ -828,7 +842,7 @@
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
@@ -836,16 +850,6 @@
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "removed",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
@@ -866,56 +870,6 @@
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "epoch_start",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "through_seq",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "initial_values",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "current_values",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": false,
@@ -1226,16 +1180,6 @@
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_seq",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
@@ -1557,13 +1501,9 @@
"table": "session_share"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1572,13 +1512,9 @@
"table": "workspace"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1587,13 +1523,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1602,13 +1534,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1617,13 +1545,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1632,13 +1556,20 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_checkpoint_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_checkpoint"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1647,28 +1578,9 @@
"table": "instruction_entry"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_state_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_state"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1677,13 +1589,9 @@
"table": "message"
},
{
"columns": [
"message_id"
],
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1692,13 +1600,9 @@
"table": "part"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1707,13 +1611,9 @@
"table": "session_message"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1722,13 +1622,9 @@
"table": "session_pending"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1737,13 +1633,9 @@
"table": "session"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1752,183 +1644,133 @@
"table": "session_share"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"key"
],
"columns": ["session_id", "key"],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"columns": ["name"],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"hash"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@@ -2237,5 +2079,5 @@
"table": "session"
}
],
"renames": []
}
"renames": ["session_input->session_pending"]
}
+19 -29
View File
@@ -119,11 +119,6 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
path: AbsolutePath,
}) {}
export class File extends Schema.Class<File>("Config.File")({
type: Schema.Literal("file"),
path: AbsolutePath,
}) {}
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
type: Schema.Literal("agents"),
path: AbsolutePath,
@@ -134,7 +129,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
path: AbsolutePath,
}) {}
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
@@ -143,7 +138,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
}
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
/** Returns location config documents and supplemental directories from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
}
@@ -232,36 +227,31 @@ const layer = Layer.effect(
const directPaths = discovered
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
const direct = yield* Effect.forEach(directPaths, (filepath) =>
loadFile(filepath).pipe(
Effect.map((config) => [
...(config ? [config] : []),
new File({ type: "file", path: AbsolutePath.make(filepath) }),
]),
),
).pipe(
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
return {
entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
const targets = [
...directories.map((path) => ({ path, type: "directory" as const })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
const next = new Map(targets.map((target) => [JSON.stringify(target), target]))
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
for (const [key, stop] of subscriptions) {
if (next.has(key)) continue
yield* stop
@@ -282,7 +272,7 @@ const layer = Layer.effect(
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
@@ -8,7 +8,6 @@ import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath, RelativePath } from "../schema"
@@ -74,7 +73,6 @@ const layer = Layer.effect(
const events = yield* EventV2.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
@@ -88,12 +86,6 @@ const layer = Layer.effect(
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
@@ -151,5 +143,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
})
-1
View File
@@ -53,6 +53,5 @@ export const migrations = (
import("./migration/20260709025533_drop-todo"),
import("./migration/20260709163752_time_suspended"),
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,86 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260710025429_instruction_sync",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`__new_instruction_entry\`(
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
)
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
FROM \`instruction_entry\`;
`)
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`
CREATE TABLE \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
// Persisted System rows were exclusively pre-beta instruction prose,
// including fork copies whose message IDs no longer match the source event.
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
yield* tx.run(`
UPDATE \`session\`
SET \`fork_seq\` = COALESCE(
(
SELECT MIN(\`seq\`) - 1
FROM \`event\`
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
),
(
SELECT \`seq\`
FROM \`event_sequence\`
WHERE \`aggregate_id\` = \`session\`.\`id\`
),
0
)
WHERE \`fork_session_id\` IS NOT NULL;
`)
yield* tx.run(`
UPDATE \`event\`
SET
\`type\` = 'session.forked.2',
\`data\` = json_set(
\`data\`,
'$.parentSeq',
COALESCE(
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
0
)
)
WHERE \`type\` = 'session.forked.1';
`)
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
})
},
} satisfies DatabaseMigration.Migration
+7 -16
View File
@@ -126,33 +126,25 @@ export default {
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
CREATE TABLE \`instruction_checkpoint\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_instruction_checkpoint_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
@@ -206,7 +198,6 @@ export default {
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`fork_seq\` integer,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
+17 -6
View File
@@ -137,8 +137,7 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: Subscribe
/**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
* inherited prefix before their first child-authored event. `follow: false`
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
* completes at the end of the log; `follow: true` replays then transitions
* to live. Both modes emit one `Synced` marker at the captured replay
* watermark.
@@ -204,6 +203,7 @@ export const layerWith = (options?: LayerOptions) =>
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
@@ -260,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
@@ -515,6 +515,18 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
@@ -715,10 +727,9 @@ export const layerWith = (options?: LayerOptions) =>
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
const list = projectors.get(key) ?? []
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(key, list)
projectors.set(definition.type, list)
})
return Service.of({
+23 -25
View File
@@ -16,9 +16,6 @@ export type Info = typeof Info.Type
export const Field = Form.Field
export type Field = Form.Field
export const Fields = Form.Fields
export type Fields = Form.Fields
export const When = Form.When
export type When = Form.When
@@ -67,7 +64,9 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
message: Schema.String,
}) {}
export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
export interface ReplyInput {
readonly id: ID
@@ -75,7 +74,7 @@ export interface ReplyInput {
}
export interface ListInput {
readonly sessionID?: Form.Info["sessionID"]
readonly sessionID?: Form.FormInfo["sessionID"]
}
export interface Interface {
@@ -126,15 +125,20 @@ export const layer = Layer.effect(
const id = input.id ?? ID.create()
const existing = yield* Cache.getSuccess(forms, id)
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
const invalid = validateFields(input.fields)
if (invalid) return yield* new InvalidFormError({ message: invalid })
const form: Info = {
if (input.mode === "form") {
const invalid = validateFields(input.fields)
if (invalid) return yield* new InvalidFormError({ message: invalid })
}
const base = {
id,
sessionID: input.sessionID,
title: input.title,
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
@@ -224,16 +228,16 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
if (form.mode === "url") {
if (Object.keys(answer).length === 0) return
return "URL forms must be answered with an empty answer"
}
const fields = new Map(form.fields.map((field) => [field.key, field]))
for (const key of Object.keys(answer)) {
if (!fields.has(key)) return `Unknown form field: ${key}`
}
for (const field of form.fields) {
const value = answer[field.key]
if (field.type === "external") {
if (value !== true) return `External form field must be acknowledged: ${field.key}`
continue
}
const active = isActive(field, answer)
if (value === undefined) {
if (field.required && active) return `Missing required form field: ${field.key}`
@@ -245,9 +249,7 @@ function validateAnswer(form: Info, answer: Answer) {
}
}
type InputField = Exclude<Form.Field, Form.ExternalField>
function isActive(field: InputField, answer: Answer) {
function isActive(field: Form.Field, answer: Answer) {
if (!field.when) return true
return field.when.every((when) => matches(when, answer[when.key]))
}
@@ -265,13 +267,9 @@ function matches(when: Form.When, value: Form.Value | undefined) {
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
// silently never matching.
function validateFields(fields: ReadonlyArray<Form.Field>) {
if (fields.length === 0) return "Form must have at least one field"
const earlier = new Map<string, InputField>()
const keys = new Set<string>()
const earlier = new Map<string, Form.Field>()
for (const field of fields) {
if (keys.has(field.key)) return `Duplicate form field key: ${field.key}`
keys.add(field.key)
if (field.type === "external") continue
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
for (const when of field.when ?? []) {
const target = earlier.get(when.key)
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
@@ -282,7 +280,7 @@ function validateFields(fields: ReadonlyArray<Form.Field>) {
}
}
function validateWhen(when: Form.When, target: InputField) {
function validateWhen(when: Form.When, target: Form.Field) {
if (target.type === "boolean") {
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
return
@@ -299,7 +297,7 @@ function validateWhen(when: Form.When, target: InputField) {
}
}
function validateField(field: InputField, value: Form.Value): string | undefined {
function validateField(field: Form.Field, value: Form.Value): string | undefined {
if (field.type === "string") {
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
+8 -6
View File
@@ -189,14 +189,16 @@ const layer = Layer.effect(
if (!dotgit) return undefined
const cwd = path.dirname(dotgit)
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
if (!gitDir || !commonDir) return undefined
const git = run(cwd, proc)
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
const gitDir = yield* git(["rev-parse", "--git-dir"])
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined
return new Repository({
worktree: AbsolutePath.make(topLevel ? resolvePath(cwd, topLevel) : cwd),
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir)),
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir)),
worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)),
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
})
})
-214
View File
@@ -1,214 +0,0 @@
export * as CopilotModels from "./models"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { ModelV2 } from "../model"
import { ProviderV2 } from "../provider"
const RemoteModel = Schema.Struct({
model_picker_enabled: Schema.Boolean,
id: Schema.String,
name: Schema.String,
version: Schema.String,
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: Schema.optional(Schema.Struct({ state: Schema.optional(Schema.String) })),
billing: Schema.optional(
Schema.Struct({
token_prices: Schema.optional(
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
}),
),
}),
),
capabilities: Schema.Struct({
family: Schema.String,
limits: Schema.optional(
Schema.Struct({
max_context_window_tokens: Schema.optional(Schema.Number),
max_output_tokens: Schema.optional(Schema.Number),
max_prompt_tokens: Schema.optional(Schema.Number),
vision: Schema.optional(
Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.optional(Schema.Boolean),
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.optional(Schema.Boolean),
vision: Schema.optional(Schema.Boolean),
}),
}),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
type UsableModel = RemoteModel & {
capabilities: RemoteModel["capabilities"] & {
limits: NonNullable<RemoteModel["capabilities"]["limits"]> & {
max_output_tokens: number
max_prompt_tokens: number
}
supports: RemoteModel["capabilities"]["supports"] & { tool_calls: boolean }
}
}
export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly ModelV2.Info[]) {
const response = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
})
if (!response.ok) throw new Error(`Failed to fetch Copilot models: ${response.status}`)
const remote = new Map(
decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model && usable(model) ? ([[model.id, model]] as const) : []
}),
)
const result = new Map(existing.map((model) => [model.id, model]))
// Keep aliases and local metadata, but only when their advertised API model
// still exists. A partial or malformed item cannot create a broken model.
for (const [id, model] of result) {
const current = remote.get(model.modelID)
if (!current) {
result.delete(id)
continue
}
result.set(id, build(id, current, baseURL, model))
}
for (const [id, model] of remote) {
const key = ModelV2.ID.make(id)
if (result.has(key)) continue
result.set(key, build(key, model, baseURL))
}
return result
}
function usable(model: RemoteModel): model is UsableModel {
return (
model.policy?.state !== "disabled" &&
model.capabilities.limits?.max_output_tokens !== undefined &&
model.capabilities.limits.max_prompt_tokens !== undefined &&
model.capabilities.supports.tool_calls !== undefined
)
}
function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: ModelV2.Info) {
const messages = remote.supported_endpoints?.includes("/v1/messages") ?? false
const endpoint = messages
? "messages"
: remote.supported_endpoints?.includes("/responses")
? "responses"
: remote.supported_endpoints?.includes("/chat/completions")
? "chat"
: undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const prices = remote.billing?.token_prices
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
const version = remote.version.startsWith(`${remote.id}-`)
? remote.version.slice(remote.id.length + 1)
: remote.version
const released = previous?.time.released || Date.parse(version)
return ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
id,
modelID: ModelV2.ID.make(remote.id),
providerID: ProviderV2.ID.githubCopilot,
family: previous?.family ?? ModelV2.Family.make(remote.capabilities.family),
name: previous?.name ?? remote.name,
package: ProviderV2.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
settings: ProviderV2.mergeOverlay(previous?.settings, {
baseURL: messages ? `${baseURL}/v1` : baseURL,
...(endpoint ? { endpoint } : {}),
}),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.capabilities.supports.tool_calls,
input: image ? ["text", "image"] : ["text"],
output: ["text"],
},
variants: variants(remote, messages),
time: { released: Number.isFinite(released) ? released : 0 },
cost: [
{
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
write: Money.USDPerMillionTokens.zero,
},
},
],
status: "active",
enabled: remote.model_picker_enabled,
limit: {
context: remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
})
}
function variants(remote: UsableModel, messages: boolean): ModelV2.Info["variants"] {
const efforts = remote.capabilities.supports.reasoning_effort ?? []
if (!messages && efforts.length) {
return efforts.map((effort) => ({
id: ModelV2.VariantID.make(effort),
settings: {
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
}))
}
if (efforts.length && remote.capabilities.supports.adaptive_thinking) {
return efforts.map((effort) => ({
id: ModelV2.VariantID.make(effort),
settings: {
thinking: {
type: "adaptive",
...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}),
},
effort,
},
}))
}
const max = remote.capabilities.supports.max_thinking_budget
if (max === undefined) return []
return [
{
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: max - 1 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: Math.floor(max / 2) } },
},
]
}
+12 -10
View File
@@ -31,17 +31,15 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({
const source = (value: ReadonlyArray<File> | Instructions.Unavailable) =>
Instructions.make({
key,
codec: Schema.toCodecJson(Files),
read: Effect.succeed(value),
render: {
initial: render,
changed: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
},
load: Effect.succeed(value),
baseline: render,
update: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
})
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
@@ -84,7 +82,11 @@ const layer = Layer.effect(
load: () =>
observe().pipe(
Effect.map((files) =>
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
files === Instructions.unavailable
? source(files)
: files.length === 0
? Instructions.empty
: source(files),
),
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
+15 -20
View File
@@ -15,34 +15,29 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const environment = [
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const instructions = Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
),
render: {
initial: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
},
load: Effect.succeed(environment),
baseline: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
update: (_previous, date) => `Today's date is now: ${date}`,
}),
])
+231 -192
View File
@@ -1,70 +1,80 @@
export * as Instructions from "./index"
import { createHash } from "crypto"
import { Instruction } from "@opencode-ai/schema/instruction"
import { Data, Effect, Option, Schema } from "effect"
export const Key = Instruction.Key
export type Key = Instruction.Key
export const Hash = Instruction.Hash
export type Hash = Instruction.Hash
export const Values = Instruction.Values
export type Values = Instruction.Values
export const Delta = Instruction.Delta
export type Delta = Instruction.Delta
type NonValue = Data.TaggedEnum<{ Unavailable: {}; Removed: {} }>
const NonValue = Data.taggedEnum<NonValue>()
/** The read failed temporarily; the stored value stands. */
export const unavailable = NonValue.Unavailable()
export type Unavailable = typeof unavailable
/** An observed absence: the source exists but its value is gone. */
export const removed = NonValue.Removed()
export type Removed = typeof removed
import { Effect, Option, Schema } from "effect"
/**
* One composable instruction source over canonical JSON the same
* representation that is hashed, stored, and replayed. `make` builds one from
* a typed definition; renderers returning `undefined` skip (undecodable or
* unrenderable historical values).
* Models privileged instructions as independently refreshable typed sources.
*
* `Source<A>` describes how to observe, compare, and render one value. `make`
* closes over `A`, producing opaque `Instructions` that compose uniformly with
* instructions built from other value types.
*
* The durable `Applied` record tracks what the model was last told, per source:
* it is the model's current belief. Interpreters uphold one invariant
* `reconcile` never rewrites the baseline; it only narrates drift as update
* text. Only `rebaseline` (compaction) and `initialize` (first step) produce
* baseline text.
*
* Returning `unavailable` means observation failed temporarily. It differs from
* removing a source from the instructions: the model's prior belief stands.
* `reconcile` retains the applied value silently, and `rebaseline` restates the
* belief by rendering the last-applied value instead of a live observation.
*
* @module
*/
export interface Source {
/** Stable namespaced identity for one independently refreshable instruction source. */
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
Schema.brand("Instructions.Key"),
)
export type Key = typeof Key.Type
/** Indicates that a source could not be observed without treating it as removed. */
export const unavailable = Symbol.for("@opencode/Instructions.Unavailable")
export type Unavailable = typeof unavailable
/** Defines one typed source before its value type is hidden by `make`. */
export interface Source<A> {
readonly key: Key
readonly read: Effect.Effect<Schema.Json | Unavailable | Removed>
readonly initial: (value: Schema.Json) => string | undefined
readonly changed: (previous: Schema.Json, current: Schema.Json) => string | undefined
readonly removed: (previous: Schema.Json) => string | undefined
readonly codec: Schema.Codec<A, Schema.Json, never, never>
readonly load: Effect.Effect<A | Unavailable>
readonly baseline: (current: A) => string
readonly update: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
export declare namespace Source {
/** The typed definition supplied when constructing a source. */
export interface Definition<A> {
readonly key: Key
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: Effect.Effect<A | Unavailable | Removed>
readonly render: {
readonly initial: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
}
const InstructionsTypeId: unique symbol = Symbol.for("@opencode/Instructions")
/** Opaque carrier for composable instruction sources. */
export interface Instructions {
readonly [InstructionsTypeId]: ReadonlyArray<PackedSource>
}
/** Ordered sources; identical values render identical bytes. */
export type Instructions = ReadonlyArray<Source>
/** The value last applied to the model for one admitted source. */
export const AppliedSource = Schema.Struct({
value: Schema.Json,
removed: Schema.optional(Schema.NonEmptyString),
})
export type AppliedSource = typeof AppliedSource.Type
export type ReadResult = ReadonlyArray<{
readonly key: Key
readonly value: Schema.Json | Unavailable | Removed
}>
/** Durable record of what the model currently believes, per source. */
export const Applied = Schema.Record(Key, AppliedSource)
export type Applied = Readonly<Record<string, AppliedSource>>
export interface Admission {
readonly delta: Delta
readonly blobs: Readonly<Record<string, Schema.Json>>
/** A rendered baseline together with the applied values it was rendered from. */
export interface Baseline {
readonly text: string
readonly applied: Applied
}
export interface Updated {
readonly _tag: "Updated"
readonly text: string
readonly applied: Applied
}
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"Instructions.InitializationBlocked",
{ keys: Schema.Array(Key) },
@@ -82,140 +92,71 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const empty: Instructions = []
interface PackedSource {
readonly key: Key
readonly load: Effect.Effect<Observed | Unavailable>
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
readonly recall: (stored: AppliedSource) => string | undefined
}
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
export function make<A>(source: Source.Definition<A>): Instructions {
interface Observed {
readonly applied: AppliedSource
readonly baseline: () => string
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
readonly update: (previous: AppliedSource) => string | undefined
}
interface Entry {
readonly key: Key
readonly recall: PackedSource["recall"]
readonly observed: Observed | Unavailable
}
/** The identity instruction set. */
export const empty = instructions([])
/** Closes a typed source into instructions that compose with differently typed sources. */
export function make<A>(source: Source<A>): Instructions {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
return [
const equivalent = Schema.toEquivalence(source.codec)
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
return instructions([
{
key: source.key,
read: source.read.pipe(
recall: (stored) =>
Option.match(decode(stored.value), {
onNone: () => undefined,
onSome: baseline,
}),
load: source.load.pipe(
Effect.map((value) => {
if (isUnavailable(value)) return unavailable
if (isRemoved(value)) return removed
return encode(value)
if (isUnavailable(value)) return value
return {
applied: {
value: encode(value),
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
},
baseline: () => baseline(value),
update: (previous) =>
Option.match(decode(previous.value), {
onNone: () => baseline(value),
onSome: (decoded) =>
equivalent(decoded, value)
? undefined
: requireText(source.key, "update", source.update(decoded, value)),
}),
} satisfies Observed
}),
),
initial: (value) => {
const decoded = decodeValue(value)
return decoded === undefined ? undefined : initial(decoded)
},
changed: (previous, current) => {
const before = decodeValue(previous)
const after = decodeValue(current)
if (after === undefined) return undefined
if (before === undefined) return initial(after)
return requireText(source.key, "changed", source.render.changed(before, after))
},
removed: (previous) => {
const decoded = decodeValue(previous)
return decoded === undefined || source.render.removed === undefined
? undefined
: requireText(source.key, "removed", source.render.removed(decoded))
},
},
]
}
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flat()
const keys = new Set<Key>()
for (const source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
}
return sources
}
export function read(value: Instructions): Effect.Effect<ReadResult> {
return Effect.forEach(
value,
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
{ concurrency: "unbounded" },
)
}
export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Admission, InitializationBlocked> {
const blocked = previous ? [] : observed.flatMap((entry) => (isUnavailable(entry.value) ? [entry.key] : []))
if (blocked.length > 0) return Effect.fail(new InitializationBlocked({ keys: blocked }))
const delta: Record<string, Hash | Instruction.Removed> = {}
const blobs: Record<string, Schema.Json> = {}
for (const entry of observed) {
if (isUnavailable(entry.value)) continue
if (isRemoved(entry.value)) {
if (previous && Object.hasOwn(previous, entry.key)) delta[entry.key] = Instruction.removed
continue
}
const next = hash(entry.value)
if (previous?.[entry.key] === next) continue
delta[entry.key] = next
blobs[next] = entry.value
}
return Effect.succeed({ delta, blobs })
}
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(values, source.key)) return []
const text = source.initial(values[source.key])
return text === undefined ? [] : [text]
}),
)
}
export function renderUpdate(
value: Instructions,
previous: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(delta, source.key)) return []
const current = delta[source.key]
if (Option.isNone(current)) {
if (!Object.hasOwn(previous, source.key)) return []
const text = source.removed(previous[source.key])
return text === undefined ? [] : [text]
}
const next = current.value
const text = Object.hasOwn(previous, source.key)
? source.changed(previous[source.key], next)
: source.initial(next)
return text === undefined ? [] : [text]
}),
)
}
export function hash(value: Schema.Json) {
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
}
export function applyDelta(
values: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
): Readonly<Record<string, Schema.Json>> {
const result: Record<string, Schema.Json> = { ...values }
for (const [key, value] of Object.entries(delta)) {
if (Option.isNone(value)) delete result[key]
else result[key] = value.value
}
return result
}
export function applyHashDelta(values: Values, delta: Delta): Values {
const result: Record<string, Hash> = { ...values }
for (const [key, value] of Object.entries(delta)) {
if (value === Instruction.removed) delete result[key]
else result[key] = value
}
return result
])
}
/**
* Keyed three-way diff for list-shaped sources rendering delta updates.
* `changed` compares two values sharing a key; entries equal under it are dropped.
*/
export function diffByKey<A>(
previous: ReadonlyArray<A>,
current: ReadonlyArray<A>,
@@ -238,31 +179,129 @@ export function diffByKey<A>(
}
}
/** Combines instructions in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flatMap((value) => value[InstructionsTypeId])
assertUniqueKeys(sources)
return instructions(sources)
}
const observe = (value: Instructions) =>
Effect.forEach(
value[InstructionsTypeId],
(source) =>
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
{ concurrency: "unbounded" },
)
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
export function initialize(value: Instructions): Effect.Effect<Baseline, InitializationBlocked> {
return observe(value).pipe(
Effect.flatMap((entries) => {
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
const parts: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
if (entry.observed === unavailable) continue
parts.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
}
return Effect.succeed({ text: render(parts), applied })
}),
)
}
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
export function reconcile(value: Instructions, previous: Applied): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const updates: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
const stored = get(previous, entry.key)
if (entry.observed === unavailable) {
// The prior belief stands while the source cannot be observed.
if (stored) applied[entry.key] = stored
continue
}
if (!stored) {
updates.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
continue
}
const text = entry.observed.update(stored)
if (text === undefined) {
applied[entry.key] = stored
continue
}
updates.push(text)
applied[entry.key] = entry.observed.applied
}
const keys = new Set<string>(entries.map((entry) => entry.key))
for (const key of Object.keys(previous).sort()) {
if (keys.has(key)) continue
const removed = previous[key].removed
// An unannounced removal retains the belief; it clears at the next rebaseline.
if (removed === undefined) applied[key] = previous[key]
else updates.push(removed)
}
if (updates.length === 0) return { _tag: "Unchanged" }
return { _tag: "Updated", text: render(updates), applied }
}),
)
}
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
export function rebaseline(value: Instructions, previous: Applied): Effect.Effect<Baseline> {
return observe(value).pipe(
Effect.map((entries): Baseline => {
const parts: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
if (entry.observed !== unavailable) {
parts.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
continue
}
const stored = get(previous, entry.key)
if (!stored) continue
const text = entry.recall(stored)
// An undecodable belief cannot be restated; the source re-announces when observable again.
if (text === undefined) continue
parts.push(text)
applied[entry.key] = stored
}
return { text: render(parts), applied }
}),
)
}
function instructions(sources: ReadonlyArray<PackedSource>): Instructions {
return { [InstructionsTypeId]: sources }
}
function render(parts: ReadonlyArray<string>) {
return parts.join("\n\n")
}
// Reference-equality guards: `A` in a typed source may itself be JSON shaped
// like these singletons, so identity, never structure, discriminates.
function get(applied: Applied, key: Key) {
return Object.hasOwn(applied, key) ? applied[key] : undefined
}
function isUnavailable(value: unknown): value is Unavailable {
return value === unavailable
}
function isRemoved(value: unknown): value is Removed {
return value === removed
}
function canonical(value: Schema.Json): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (value !== null && typeof value === "object")
return `{${Object.entries(value)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
.join(",")}}`
return JSON.stringify(value)
}
function requireText(key: Key, kind: string, text: string) {
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
return text
}
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
const keys = new Set<Key>()
for (const source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
}
}
+10 -14
View File
@@ -70,19 +70,8 @@ export const layer = Layer.effect(
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
read: Effect.succeed(value),
render: {
initial: render,
changed: update,
removed: () => "MCP server instructions are no longer available.",
},
})
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return source(Instructions.removed)
return Instructions.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
@@ -99,8 +88,15 @@ export const layer = Layer.effect(
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
.toSorted((a, b) => a.server.localeCompare(b.server))
return source(visible.length === 0 ? Instructions.removed : visible)
if (visible.length === 0) return Instructions.empty
return Instructions.make({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible),
baseline: render,
update,
removed: () => "MCP server instructions are no longer available.",
})
}),
})
}),
+7 -8
View File
@@ -123,7 +123,6 @@ type ServerEntry = {
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
const GLOBAL_ELICITATION_SESSION_ID = "global"
const URL_ELICITATION_FIELD_KEY = "elicitation"
export interface Interface {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -312,7 +311,8 @@ export const layer = Layer.effect(
elicitationID: input.params.elicitationId,
message: input.params.message,
},
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
mode: "url",
url: input.params.url,
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
@@ -325,16 +325,15 @@ export const layer = Layer.effect(
)
}
const params = input.params
const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
)
if (!field) return { action: "accept", content: {} }
return yield* forms
.ask({
sessionID: GLOBAL_ELICITATION_SESSION_ID,
title: `${input.server} is requesting input`,
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
fields: [field, ...fields],
mode: "form",
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
),
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
@@ -356,7 +355,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
+4 -22
View File
@@ -21,14 +21,10 @@ import { ToolHooks } from "./tool/hooks"
import { PluginHooks } from "./plugin/hooks"
export interface Interface {
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
readonly activate: (plugins: readonly Plugin[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
}
export interface Versioned extends Plugin {
readonly version: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
const layer = Layer.effect(
@@ -36,11 +32,11 @@ const layer = Layer.effect(
Effect.gen(function* () {
const events = yield* EventV2.Service
const scope = yield* Scope.make()
const active = new Map<typeof ID.Type, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<typeof ID.Type, { readonly plugin: Plugin; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let host: Parameters<Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const load = Effect.fnUntraced(function* (plugin: Plugin) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
@@ -59,7 +55,7 @@ const layer = Layer.effect(
return undefined
})
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Plugin[]) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) }))
const ids = new Set<typeof ID.Type>()
for (const definition of definitions) {
@@ -69,20 +65,6 @@ const layer = Layer.effect(
yield* lock.withPermit(
Effect.gen(function* () {
const next = definitions.map((definition) => ({ id: definition.id, version: definition.version }))
const current = Array.from(active.values(), (entry) => ({
id: entry.plugin.id,
version: entry.plugin.version,
}))
if (
current.length === next.length &&
current.every((definition, index) => {
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
})
)
return
yield* State.batch(
Effect.gen(function* () {
for (const definition of definitions) {
+2
View File
@@ -1,6 +1,7 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node"
@@ -8,6 +9,7 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks
}
+1 -1
View File
@@ -367,6 +367,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
runtime.session.create({
id: input?.id,
@@ -378,7 +379,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt,
command: runtime.session.command,
rename: runtime.session.rename,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
},
} satisfies Plugin.Context
+1 -4
View File
@@ -26,10 +26,7 @@ export const ModelsDevPlugin = define({
})
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, provider.info)
draft.integrationID = provider.info.id
})
catalog.provider.update(provider.info.id, (draft) => Object.assign(draft, provider.info))
for (const model of provider.models) {
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
+26 -172
View File
@@ -1,24 +1,12 @@
export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Tool } from "../tool/tool"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect, Scope, Stream } from "effect"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin
type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@@ -29,8 +17,8 @@ type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: s
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
export function fromPromise(plugin: PromisePlugin) {
return Plugin.define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
@@ -43,7 +31,7 @@ export function fromPromise(plugin: Plugin) {
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect).then(wire)
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const transform =
<Draft>(domain: {
@@ -56,7 +44,7 @@ export function fromPromise(plugin: Plugin) {
}),
)
const context2: Context = {
const context2: PromisePluginContext = {
options: host.options,
agent: {
list: (input) => run(host.agent.list(input)),
@@ -70,13 +58,11 @@ export function fromPromise(plugin: Plugin) {
catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) =>
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
get: (input) => run(host.catalog.provider.get(input)),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
default: (input) =>
run(host.catalog.model.default(input)).then((result) => ({ ...result, data: result.data ?? null })),
default: (input) => run(host.catalog.model.default(input)),
},
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
@@ -87,50 +73,19 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.command.reload()),
},
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe().pipe(Stream.map(wireEvent))),
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
},
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) =>
run(host.integration.get({ ...input, integrationID: Integration.ID.make(input.integrationID) })).then(
(result) => ({ ...result, data: result.data ?? null }),
),
get: (input) => run(host.integration.get(input)),
connect: {
key: (input) =>
run(
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
),
oauth: (input) =>
run(
host.integration.connect.oauth({
...input,
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
}),
),
key: (input) => run(host.integration.connect.key(input)),
oauth: (input) => run(host.integration.connect.oauth(input)),
},
attempt: {
status: (input) =>
run(
host.integration.attempt.status({
...input,
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
complete: (input) =>
run(
host.integration.attempt.complete({
...input,
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
cancel: (input) =>
run(
host.integration.attempt.cancel({
...input,
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
status: (input) => run(host.integration.attempt.status(input)),
complete: (input) => run(host.integration.attempt.complete(input)),
cancel: (input) => run(host.integration.attempt.cancel(input)),
},
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
@@ -153,123 +108,22 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.skill.reload()),
},
tool: {
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
}),
),
),
transform: transform(host.tool),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
session: {
create: (input) =>
run(
host.session.create(
input === undefined
? undefined
: {
id: input.id == null ? undefined : Session.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
location:
input.location == null
? undefined
: Location.Ref.make({
directory: AbsolutePath.make(input.location.directory),
workspaceID:
input.location.workspaceID === undefined
? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) =>
run(
host.session.prompt({
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
}),
),
command: (input) =>
run(
host.session.command({
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
arguments: input.arguments ?? undefined,
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
}),
),
rename: (input) =>
run(
host.session.rename({
...input,
sessionID: Session.ID.make(input.sessionID),
}),
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
}),
})
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),
providerID: Provider.ID.make(input.providerID),
variant: input.variant === undefined ? undefined : Model.VariantID.make(input.variant),
})
}
type Wire<Value> = unknown extends Value
? JsonValue
: Value extends string | number | boolean | bigint | symbol | null | undefined
? Value
: Value extends DateTime.DateTime
? number
: Value extends ReadonlyArray<infer Item>
? Array<Wire<Item>>
: Value extends object
? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
: Value
function wire<Value>(value: Value): Wire<Value>
function wire(value: unknown): unknown {
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
if (Array.isArray(value)) return value.map(wire)
if (typeof value !== "object" || value === null) return value
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wire(item)]))
}
function wireEvent(value: unknown): PromiseEvent
function wireEvent(value: unknown): unknown {
return wire(value)
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
})
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
})
}
@@ -1,115 +1,7 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { CopilotModels } from "../../github-copilot/models"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
const Device = Schema.Struct({
verification_uri: Schema.String,
user_code: Schema.String,
device_code: Schema.String,
interval: Schema.Number,
})
const Token = Schema.Struct({
access_token: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
interval: Schema.optional(Schema.Number),
})
const JsonBody = Schema.UnknownFromJsonString
const decodeBody = Schema.decodeUnknownOption(JsonBody)
const oauth = {
integrationID: Integration.ID.make("github-copilot"),
method: {
id: methodID,
type: "oauth",
label: "Login with GitHub Copilot",
prompts: [
{
type: "select",
key: "deploymentType",
message: "Select GitHub deployment type",
options: [
{ label: "GitHub.com", value: "github.com", hint: "Public" },
{ label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
],
},
{
type: "text",
key: "enterpriseUrl",
message: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
when: { key: "deploymentType", op: "eq", value: "enterprise" },
},
],
},
authorize: (inputs) =>
Effect.gen(function* () {
const enterprise = inputs.deploymentType === "enterprise"
if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
const urls = oauthURLs(domain)
const device = yield* request(urls.device, {
method: "POST",
headers: headers(),
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
const interval = Math.max(device.interval, 1) * 1000
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
request(urls.token, {
method: "POST",
headers: headers(),
body: JSON.stringify({
client_id: clientID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
}).pipe(
Effect.map(Schema.decodeUnknownSync(Token)),
Effect.flatMap((token) => {
if (token.access_token) {
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: token.access_token,
access: token.access_token,
expires: 0,
...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}),
}),
)
}
if (token.error === "authorization_pending")
return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait)))
if (token.error === "slow_down") {
const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000
return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next)))
}
return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`))
}),
)
return {
mode: "auto" as const,
url: device.verification_uri,
instructions: `Enter code: ${device.user_code}`,
callback: poll(interval),
}
}),
} satisfies IntegrationOAuthMethodRegistration
function shouldUseResponses(modelID: string) {
// Copilot supports Responses for GPT-5 class models, except mini variants
@@ -122,103 +14,19 @@ function shouldUseResponses(modelID: string) {
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<ModelV2.ID, ModelV2.Info>
} = {}
const load = Effect.fn("GithubCopilotPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("github-copilot")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
loaded.models = undefined
return
}
const enterprise = credential.metadata?.enterpriseUrl
loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined)
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
loaded.models = yield* Effect.tryPromise({
try: () =>
CopilotModels.get(
loaded.baseURL ?? baseURL(),
{
...provider?.headers,
Authorization: `Bearer ${credential.refresh}`,
"User-Agent": `opencode/${InstallationVersion}`,
"X-GitHub-Api-Version": apiVersion,
},
existing,
),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to sync GitHub Copilot models", { cause }).pipe(Effect.as(undefined)),
),
)
})
yield* ctx.integration.transform((draft) => {
draft.method.update(oauth)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item) return
if (loaded.models) {
for (const id of item.models.keys()) {
if (!loaded.models.has(ModelV2.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
} else if (loaded.baseURL) {
for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => {
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
}
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) {
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return
evt.options.fetch = copilotFetch(
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
evt.options.fetch,
evt.package === "@ai-sdk/anthropic",
)
if (evt.package === "@ai-sdk/anthropic") {
evt.options.headers = {
...evt.options.headers,
"anthropic-beta": "interleaved-thinking-2025-05-14",
}
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
evt.sdk = mod.createAnthropic(evt.options)
return
}
if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
@@ -244,114 +52,4 @@ export const GithubCopilotPlugin = define({
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
function normalizeDomain(input: string) {
return input.replace(/^https?:\/\//, "").replace(/\/$/, "")
}
function oauthURLs(domain: string) {
return {
device: `https://${domain}/login/device/code`,
token: `https://${domain}/login/oauth/access_token`,
}
}
function baseURL(enterprise?: string) {
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
}
function headers() {
return {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
function request(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json()
},
catch: (cause) => cause,
})
}
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch {
const send = upstream ?? fetch
return async (input, init) => {
const requestHeaders = new Headers(init?.headers)
if (token) {
requestHeaders.delete("authorization")
requestHeaders.delete("x-api-key")
requestHeaders.set("Authorization", `Bearer ${token}`)
}
requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`)
requestHeaders.set("Openai-Intent", "conversation-edits")
requestHeaders.set("X-GitHub-Api-Version", apiVersion)
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url
const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined
const metadata = requestMetadata(url, body)
requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user")
if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true")
return send(input, { ...init, headers: requestHeaders })
}
}
function requestMetadata(url: string, body: unknown) {
if (!record(body)) return { agent: false, vision: false }
if (Array.isArray(body.input)) {
const last = body.input.at(-1)
return {
agent: !record(last) || last.role !== "user",
vision: body.input.some(
(item) =>
record(item) &&
Array.isArray(item.content) &&
item.content.some((part) => record(part) && part.type === "input_image"),
),
}
}
if (!Array.isArray(body.messages)) return { agent: false, vision: false }
const last = body.messages.at(-1)
if (url.includes("completions")) {
return {
agent: !record(last) || last.role !== "user",
vision: body.messages.some(
(message) =>
record(message) &&
Array.isArray(message.content) &&
message.content.some((part) => record(part) && part.type === "image_url"),
),
}
}
const content = record(last) && Array.isArray(last.content) ? last.content : []
return {
agent:
!record(last) || last.role !== "user" || !content.some((part) => record(part) && part.type !== "tool_result"),
vision: body.messages.some(
(message) =>
record(message) &&
Array.isArray(message.content) &&
message.content.some(
(part) =>
record(part) &&
(part.type === "image" ||
(part.type === "tool_result" &&
Array.isArray(part.content) &&
part.content.some((nested) => record(nested) && nested.type === "image"))),
),
),
}
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
})
+1 -2
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface {
readonly session: Pick<
SessionV2.Interface,
"get" | "create" | "messages" | "prompt" | "command" | "rename" | "resume" | "interrupt" | "synthetic"
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -51,7 +51,6 @@ export const layerWithCell = (cell: Cell) =>
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
+3 -5
View File
@@ -4,7 +4,6 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
import type { PluginV2 } from "../plugin"
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
@@ -21,7 +20,7 @@ export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly PluginV2.Versioned[]
readonly all: () => readonly Plugin[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +29,11 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const plugins = new Map<string, PluginV2.Versioned>()
let revision = 0
const plugins = new Map<string, Plugin>()
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
plugins.set(plugin.id, plugin)
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})

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