chore: update merge branch with latest v2
This commit is contained in:
commit
7ec1ed72b4
206 changed files with 14879 additions and 10729 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -27,6 +27,7 @@ Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- 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.
|
- 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 `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
|
|
@ -154,14 +155,14 @@ const table = sqliteTable("session", {
|
||||||
## V2 Session Core
|
## V2 Session Core
|
||||||
|
|
||||||
- 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 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_input` 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.
|
- 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. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
- 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; idle or missing interruption is a no-op.
|
- 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 `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.
|
- 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 step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
- 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.
|
||||||
- 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 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.
|
- 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.
|
- 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 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 `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.
|
- 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.
|
||||||
- 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.
|
- `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.
|
||||||
|
|
|
||||||
74
CONTEXT.md
74
CONTEXT.md
|
|
@ -13,11 +13,11 @@ The opaque algebra of independently refreshable typed instruction sources that r
|
||||||
_Avoid_: Model Context, System Context
|
_Avoid_: Model Context, System Context
|
||||||
|
|
||||||
**Session History**:
|
**Session History**:
|
||||||
The projected chronological conversation selected for a **Step** after applying the active compaction and **InstructionCheckpoint** baseline cutoffs.
|
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||||
_Avoid_: Session Context
|
_Avoid_: Session Context
|
||||||
|
|
||||||
**Instruction Source**:
|
**Instruction Source**:
|
||||||
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.
|
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||||
_Avoid_: Prompt fragment
|
_Avoid_: Prompt fragment
|
||||||
|
|
||||||
**InstructionEntry**:
|
**InstructionEntry**:
|
||||||
|
|
@ -26,22 +26,25 @@ One API-managed, durable, per-Session instruction value. Its slash-free client k
|
||||||
**InstructionDiscovery**:
|
**InstructionDiscovery**:
|
||||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||||
|
|
||||||
**InstructionCheckpoint**:
|
**Instruction State**:
|
||||||
The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps.
|
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||||
|
|
||||||
**Instruction Update**:
|
**Instruction Update**:
|
||||||
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**.
|
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_: System notification, raw text diff
|
_Avoid_: Correction, stored prose, raw text diff
|
||||||
|
|
||||||
**Instruction Baseline**:
|
**Initial Instructions**:
|
||||||
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.
|
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||||
_Avoid_: Live system prompt
|
_Avoid_: Live system prompt
|
||||||
|
|
||||||
**Applied Instructions**:
|
**Instruction Epoch**:
|
||||||
The overwriteable model-hidden `Instructions.Applied` record in **InstructionCheckpoint**, containing what the model was last told per **Instruction Source**.
|
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||||
|
|
||||||
|
**Instruction Values**:
|
||||||
|
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||||
|
|
||||||
**Unavailable Instruction Source**:
|
**Unavailable Instruction Source**:
|
||||||
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**.
|
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||||
|
|
||||||
**Safe Step Boundary**:
|
**Safe Step Boundary**:
|
||||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||||
|
|
@ -53,7 +56,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**.
|
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||||
|
|
||||||
**Step**:
|
**Step**:
|
||||||
One logical LLM call spanning pre-flight instruction checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||||
_Avoid_: provider turn, turn (unqualified)
|
_Avoid_: provider turn, turn (unqualified)
|
||||||
|
|
||||||
**Physical Attempt**:
|
**Physical Attempt**:
|
||||||
|
|
@ -108,18 +111,16 @@ _Avoid_: Response envelope
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
- **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, the **Instruction Baseline**, **Session History**, available tools, and step-local additions into one model request.
|
- **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** contains projected conversational messages and admitted **Instruction Updates**; the active **Instruction Baseline** remains separate provider-request state.
|
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
- 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.
|
- `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** 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.
|
- 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.initialize(...)` observes composed **Instructions** once and produces a complete **Instruction Baseline** with **Applied Instructions**.
|
- `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.reconcile(...)` observes composed **Instructions** once and returns either unchanged or one combined chronological update. It never rewrites the baseline.
|
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||||
- `Instructions.rebaseline(...)` renders a fresh baseline after completed compaction, recalling previously applied values for sources that are temporarily unavailable.
|
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||||
- A changed **Instruction Source** may contribute text to one **Instruction Update** containing the newly effective state.
|
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||||
- An **Instruction Update** persists the exact combined rendered text sent to the model through `session.instructions.updated`.
|
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||||
- **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**.
|
- 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.
|
- 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.
|
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||||
|
|
@ -130,28 +131,27 @@ _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.
|
- 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.
|
- 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.
|
- 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** 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.
|
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
- 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 rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history.
|
- 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 **Applied Instructions** emits its baseline rendering once at the next **Safe Step Boundary**.
|
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
- **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**.
|
- **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.
|
- 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.
|
- 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**.
|
- **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.
|
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||||
- 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.
|
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||||
- **Instruction Updates** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||||
- An **Instruction Baseline** is stored durably and reused verbatim across process restarts until rebaseline or reset.
|
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||||
- An **Instruction Baseline** durably preserves the exact joined text used for its part of the active provider-cache prefix.
|
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||||
- 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.
|
- **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.
|
- **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.
|
- **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.
|
- 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.
|
- 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.
|
- 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.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.
|
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
- `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.
|
- 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.
|
- 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.
|
- `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.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.
|
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
- 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.
|
- 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.
|
- 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,10 +195,10 @@ _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.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.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.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 system text, **Instruction Baseline**, 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 agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
- **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.
|
- `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(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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 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 truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
- 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** 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.
|
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
- 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.
|
- 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.
|
- 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. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
- 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
|
## Client contract architecture
|
||||||
|
|
|
||||||
4
bun.lock
4
bun.lock
|
|
@ -716,8 +716,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|
@ -792,6 +790,8 @@
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||||
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node"
|
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Logger, References } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Commands } from "./commands/commands"
|
import { Commands } from "./commands/commands"
|
||||||
import { Runtime } from "./framework/runtime"
|
import { Runtime } from "./framework/runtime"
|
||||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
import { Observability } from "@opencode-ai/core/observability"
|
||||||
import { Updater } from "./services/updater"
|
import { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
|
@ -12,12 +12,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
|
|
||||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
Layer.orDie,
|
|
||||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
api: () => import("./commands/handlers/api"),
|
api: () => import("./commands/handlers/api"),
|
||||||
|
|
@ -59,7 +53,7 @@ Effect.logInfo("cli starting", {
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||||
Effect.provide(LoggingLayer),
|
Effect.provide(Observability.layer),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
|
".": "./src/promise/index.ts",
|
||||||
"./promise": "./src/promise/index.ts",
|
"./promise": "./src/promise/index.ts",
|
||||||
"./promise/api": "./src/promise/api.ts",
|
"./promise/api": "./src/promise/api.ts",
|
||||||
"./effect": "./src/effect/index.ts",
|
"./effect": "./src/effect/index.ts",
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,7 @@ await Effect.runPromise(
|
||||||
[
|
[
|
||||||
write(
|
write(
|
||||||
emitPromise(promiseContract, {
|
emitPromise(promiseContract, {
|
||||||
outputTypes: {
|
mutableOutputs: true,
|
||||||
"events.subscribe": {
|
|
||||||
name: "OpenCodeEventEncoded",
|
|
||||||
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
|
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -226,64 +226,69 @@ export type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["param
|
||||||
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||||
|
|
||||||
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
|
||||||
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
||||||
export type Endpoint5_21Output = EffectValue<
|
export type Endpoint5_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.pending.list"]>>["data"]
|
||||||
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||||
|
|
||||||
|
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
||||||
|
export type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
|
||||||
|
export type Endpoint5_22Output = EffectValue<
|
||||||
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
|
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
|
||||||
>["data"]
|
>["data"]
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
input: Endpoint5_21Input,
|
|
||||||
) => Effect.Effect<Endpoint5_21Output, E>
|
|
||||||
|
|
||||||
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
|
||||||
export type Endpoint5_22Input = {
|
|
||||||
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
|
|
||||||
readonly key: Endpoint5_22Request["params"]["key"]
|
|
||||||
readonly value: Endpoint5_22Request["payload"]["value"]
|
|
||||||
}
|
|
||||||
export type Endpoint5_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
|
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
|
||||||
input: Endpoint5_22Input,
|
input: Endpoint5_22Input,
|
||||||
) => Effect.Effect<Endpoint5_22Output, E>
|
) => Effect.Effect<Endpoint5_22Output, E>
|
||||||
|
|
||||||
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
||||||
export type Endpoint5_23Input = {
|
export type Endpoint5_23Input = {
|
||||||
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_23Request["params"]["key"]
|
readonly key: Endpoint5_23Request["params"]["key"]
|
||||||
|
readonly value: Endpoint5_23Request["payload"]["value"]
|
||||||
}
|
}
|
||||||
export type Endpoint5_23Output = EffectValue<
|
export type Endpoint5_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
|
||||||
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
>
|
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
|
||||||
input: Endpoint5_23Input,
|
input: Endpoint5_23Input,
|
||||||
) => Effect.Effect<Endpoint5_23Output, E>
|
) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
||||||
export type Endpoint5_24Input = {
|
export type Endpoint5_24Input = {
|
||||||
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint5_24Request["query"]["after"]
|
readonly key: Endpoint5_24Request["params"]["key"]
|
||||||
readonly follow?: Endpoint5_24Request["query"]["follow"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint5_24Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
export type Endpoint5_24Output = EffectValue<
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_24Input) => Stream.Stream<Endpoint5_24Output, E>
|
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
|
||||||
|
>
|
||||||
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
|
input: Endpoint5_24Input,
|
||||||
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||||
export type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
|
export type Endpoint5_25Input = {
|
||||||
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
readonly after?: Endpoint5_25Request["query"]["after"]
|
||||||
|
readonly follow?: Endpoint5_25Request["query"]["follow"]
|
||||||
|
}
|
||||||
|
export type Endpoint5_25Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||||
|
export type SessionLogOperation<E = never> = (input: Endpoint5_25Input) => Stream.Stream<Endpoint5_25Output, E>
|
||||||
|
|
||||||
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
||||||
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||||
|
|
||||||
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
export type Endpoint5_27Input = {
|
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
|
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||||
readonly messageID: Endpoint5_27Request["params"]["messageID"]
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
|
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
|
export type Endpoint5_28Input = {
|
||||||
|
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint5_28Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
|
|
@ -309,6 +314,7 @@ export interface SessionApi<E = never> {
|
||||||
readonly commit: SessionRevertCommitOperation<E>
|
readonly commit: SessionRevertCommitOperation<E>
|
||||||
}
|
}
|
||||||
readonly context: SessionContextOperation<E>
|
readonly context: SessionContextOperation<E>
|
||||||
|
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||||
readonly instructions: {
|
readonly instructions: {
|
||||||
readonly entry: {
|
readonly entry: {
|
||||||
readonly list: SessionInstructionsEntryListOperation<E>
|
readonly list: SessionInstructionsEntryListOperation<E>
|
||||||
|
|
@ -544,9 +550,7 @@ export type Endpoint14_2Input = {
|
||||||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||||
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 Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
|
||||||
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
|
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
|
||||||
|
|
|
||||||
|
|
@ -318,43 +318,51 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
|
||||||
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
||||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||||
|
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
||||||
|
type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
|
||||||
|
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
||||||
type Endpoint5_22Input = {
|
type Endpoint5_23Input = {
|
||||||
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_22Request["params"]["key"]
|
readonly key: Endpoint5_23Request["params"]["key"]
|
||||||
readonly value: Endpoint5_22Request["payload"]["value"]
|
readonly value: Endpoint5_23Request["payload"]["value"]
|
||||||
}
|
}
|
||||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.instructions.entry.put"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
payload: { value: input["value"] },
|
payload: { value: input["value"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
||||||
type Endpoint5_23Input = {
|
type Endpoint5_24Input = {
|
||||||
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_23Request["params"]["key"]
|
readonly key: Endpoint5_24Request["params"]["key"]
|
||||||
}
|
}
|
||||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||||
type Endpoint5_24Input = {
|
type Endpoint5_25Input = {
|
||||||
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint5_24Request["query"]["after"]
|
readonly after?: Endpoint5_25Request["query"]["after"]
|
||||||
readonly follow?: Endpoint5_24Request["query"]["follow"]
|
readonly follow?: Endpoint5_25Request["query"]["follow"]
|
||||||
}
|
}
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
|
|
@ -365,22 +373,22 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
|
||||||
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
|
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
|
||||||
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint5_27Input = {
|
type Endpoint5_28Input = {
|
||||||
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint5_27Request["params"]["messageID"]
|
readonly messageID: Endpoint5_28Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
|
|
@ -406,11 +414,12 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||||
wait: Endpoint5_16(raw),
|
wait: Endpoint5_16(raw),
|
||||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||||
context: Endpoint5_20(raw),
|
context: Endpoint5_20(raw),
|
||||||
instructions: { entry: { list: Endpoint5_21(raw), put: Endpoint5_22(raw), remove: Endpoint5_23(raw) } },
|
pending: { list: Endpoint5_21(raw) },
|
||||||
log: Endpoint5_24(raw),
|
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||||
interrupt: Endpoint5_25(raw),
|
log: Endpoint5_25(raw),
|
||||||
background: Endpoint5_26(raw),
|
interrupt: Endpoint5_26(raw),
|
||||||
message: Endpoint5_27(raw),
|
background: Endpoint5_27(raw),
|
||||||
|
message: Endpoint5_28(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
|
|
@ -646,21 +655,12 @@ type Endpoint14_2Input = {
|
||||||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||||
readonly fields?: Endpoint14_2Request["payload"]["fields"]
|
|
||||||
readonly url?: Endpoint14_2Request["payload"]["url"]
|
|
||||||
}
|
}
|
||||||
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
|
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
|
||||||
raw["session.form.create"]({
|
raw["session.form.create"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||||
id: input["id"],
|
|
||||||
title: input["title"],
|
|
||||||
metadata: input["metadata"],
|
|
||||||
mode: input["mode"],
|
|
||||||
fields: input["fields"],
|
|
||||||
url: input["url"],
|
|
||||||
},
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export { Question } from "@opencode-ai/schema/question"
|
||||||
export { Reference } from "@opencode-ai/schema/reference"
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Skill } from "@opencode-ai/schema/skill"
|
export { Skill } from "@opencode-ai/schema/skill"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ import type {
|
||||||
SessionRevertCommitOutput,
|
SessionRevertCommitOutput,
|
||||||
SessionContextInput,
|
SessionContextInput,
|
||||||
SessionContextOutput,
|
SessionContextOutput,
|
||||||
|
SessionPendingListInput,
|
||||||
|
SessionPendingListOutput,
|
||||||
SessionInstructionsEntryListInput,
|
SessionInstructionsEntryListInput,
|
||||||
SessionInstructionsEntryListOutput,
|
SessionInstructionsEntryListOutput,
|
||||||
SessionInstructionsEntryPutInput,
|
SessionInstructionsEntryPutInput,
|
||||||
|
|
@ -666,6 +668,19 @@ export function make(options: ClientOptions) {
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
pending: {
|
||||||
|
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionPendingListOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
},
|
||||||
instructions: {
|
instructions: {
|
||||||
entry: {
|
entry: {
|
||||||
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>
|
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>
|
||||||
|
|
@ -686,7 +701,7 @@ export function make(options: ClientOptions) {
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`,
|
||||||
body: { value: input["value"] },
|
body: { value: input["value"] },
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 413, 400, 401],
|
||||||
empty: true,
|
empty: true,
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
|
|
@ -1041,14 +1056,7 @@ export function make(options: ClientOptions) {
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||||
body: {
|
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||||
id: input["id"],
|
|
||||||
title: input["title"],
|
|
||||||
metadata: input["metadata"],
|
|
||||||
mode: input["mode"],
|
|
||||||
fields: input["fields"],
|
|
||||||
url: input["url"],
|
|
||||||
},
|
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 409, 400, 401],
|
declaredStatuses: [404, 409, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,25 +1,10 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
||||||
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
|
||||||
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
|
||||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Location } from "@opencode-ai/schema/location"
|
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
|
||||||
import { Api } from "@opencode-ai/server/api"
|
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
|
||||||
import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
|
|
||||||
|
|
||||||
const Client = await import("../src/effect")
|
const Client = await import("../src/effect")
|
||||||
|
|
||||||
|
|
@ -29,34 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||||
expect(Client.Session).toBe(Session)
|
expect(Client.Session).toBe(Session)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
|
||||||
expect(CoreLocation.Ref).toBe(Location.Ref)
|
|
||||||
expect(ModelV2.Ref).toBe(Model.Ref)
|
|
||||||
expect(SessionV2.Info).toBe(Session.Info)
|
|
||||||
expect(ProjectV2.Current).toBe(Project.Current)
|
|
||||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
|
||||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
|
||||||
expect(CoreSessionInput.Message).toBe(SessionInput.Message)
|
|
||||||
expect(CoreSessionInput.User).toBe(SessionInput.User)
|
|
||||||
expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic)
|
|
||||||
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
|
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
|
||||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
|
||||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
|
||||||
expect(Project.ID.global).toBe("global")
|
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
|
||||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("client and Server contracts generate identically", () => {
|
|
||||||
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
|
||||||
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
|
||||||
|
|
||||||
expect(emitPromise(client)).toEqual(emitPromise(server))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shared DTO schemas construct and decode plain objects", () => {
|
test("shared DTO schemas construct and decode plain objects", () => {
|
||||||
const made = Prompt.make({ text: "hello" })
|
const made = Prompt.make({ text: "hello" })
|
||||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||||
|
|
@ -67,5 +24,4 @@ test("shared DTO schemas construct and decode plain objects", () => {
|
||||||
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
|
||||||
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
|
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
|
||||||
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
|
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
|
||||||
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const server = resolve(import.meta.dir, "../../server")
|
||||||
|
|
||||||
describe("public import boundaries", () => {
|
describe("public import boundaries", () => {
|
||||||
test("isolates each public entrypoint", async () => {
|
test("isolates each public entrypoint", async () => {
|
||||||
const root = await bundleInputs("@opencode-ai/client/promise", "browser")
|
const root = await bundleInputs("@opencode-ai/client", "browser")
|
||||||
|
|
||||||
expect(within(root, effect)).toEqual([])
|
expect(within(root, effect)).toEqual([])
|
||||||
expect(within(root, schema)).toEqual([])
|
expect(within(root, schema)).toEqual([])
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,34 @@ test("session instructions methods use the public HTTP contract", async () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("session.pending.list uses the public HTTP contract", async () => {
|
||||||
|
const requests: Array<{ method: string; url: string }> = []
|
||||||
|
const pending = [
|
||||||
|
{
|
||||||
|
admittedSeq: 3,
|
||||||
|
id: "msg_pending",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
timeCreated: 1_717_171_717_000,
|
||||||
|
type: "user",
|
||||||
|
data: { text: "Fix the failing tests" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
requests.push({ method: request.method, url: request.url })
|
||||||
|
return Response.json({ data: pending })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await client.session.pending.list({ sessionID: "ses_test" })
|
||||||
|
|
||||||
|
expect(result).toEqual(pending)
|
||||||
|
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ type Result = Success | Failure
|
||||||
interface Success {
|
interface Success {
|
||||||
readonly ok: true
|
readonly ok: true
|
||||||
readonly value: CodeMode.DataValue
|
readonly value: CodeMode.DataValue
|
||||||
|
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
|
||||||
readonly logs?: ReadonlyArray<string>
|
readonly logs?: ReadonlyArray<string>
|
||||||
readonly truncated?: boolean
|
readonly truncated?: boolean
|
||||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||||
|
|
@ -144,7 +145,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. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (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. 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).
|
||||||
|
|
||||||
### Tool-call hooks
|
### Tool-call hooks
|
||||||
|
|
||||||
|
|
@ -257,11 +258,11 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||||
|
|
||||||
The limits are exactly three knobs:
|
The limits are exactly three knobs:
|
||||||
|
|
||||||
| Limit | Default | Bounds |
|
| Limit | Default | Bounds |
|
||||||
| ---------------- | -------------------: | -------------------------------------------------------------------- |
|
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||||
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
|
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -279,9 +280,11 @@ 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.
|
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.
|
||||||
|
|
||||||
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`.
|
`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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -289,18 +292,19 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
|
||||||
|
|
||||||
Failures are data:
|
Failures are data:
|
||||||
|
|
||||||
| Kind | Meaning |
|
| Kind | Meaning |
|
||||||
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `ParseError` | Source is empty or cannot be parsed. |
|
| `ParseError` | Source is empty or cannot be parsed. |
|
||||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||||
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
|
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
|
||||||
| `InvalidToolOutput` | Tool output 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). |
|
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
|
||||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
|
||||||
| `ToolFailure` | A tool refused or failed. |
|
| `ToolFailure` | A tool refused or failed. |
|
||||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||||
|
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
||||||
|
|
||||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,24 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||||
|
|
||||||
### Tool execution
|
### Tool execution
|
||||||
|
|
||||||
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
|
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||||
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
|
async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested
|
||||||
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
|
functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection
|
||||||
|
is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result
|
||||||
|
wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers,
|
||||||
|
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||||
|
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||||
|
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||||
|
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
|
||||||
|
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
|
||||||
|
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
|
||||||
|
discarded. At most eight tool calls execute concurrently.
|
||||||
|
|
||||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
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
|
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||||
concurrency and data nesting depth.
|
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
||||||
|
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
|
||||||
|
host-added framing are intentionally outside the budgets.
|
||||||
|
|
||||||
### Data, files, and failures
|
### Data, files, and failures
|
||||||
|
|
||||||
|
|
@ -79,7 +90,7 @@ boundary.
|
||||||
|
|
||||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
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
|
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
||||||
data, tool failures, limits, timeouts, and execution failures.
|
data, tool failures, limits, timeouts, execution failures, and warning truncation.
|
||||||
|
|
||||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
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.
|
attach them to the outer result, but the program receives only the structured tool output.
|
||||||
|
|
@ -95,7 +106,8 @@ CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
||||||
normally.
|
normally.
|
||||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
||||||
CodeMode namespaces instead of flattened model-facing names.
|
CodeMode namespaces instead of flattened model-facing names.
|
||||||
- Each nested call checks that its captured registration is still current before dispatching it.
|
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
|
||||||
|
requests.
|
||||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
||||||
authorization.
|
authorization.
|
||||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
||||||
|
|
@ -126,18 +138,18 @@ represent accurately rather than guessing semantics.
|
||||||
|
|
||||||
## Decisions and Rationale
|
## Decisions and Rationale
|
||||||
|
|
||||||
| Decision | 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
||||||
|
|
||||||
## Remaining Work
|
## Remaining Work
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,13 +111,15 @@ ultimate source of truth.
|
||||||
- [x] `Promise.resolve` and `Promise.reject`.
|
- [x] `Promise.resolve` and `Promise.reject`.
|
||||||
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
|
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
|
||||||
values.
|
values.
|
||||||
- [x] `Promise.all` preserves result order and rejects on the first observed failure.
|
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
|
||||||
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
||||||
- [x] `Promise.race` interrupts losing in-flight tool calls.
|
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
|
||||||
- [x] Un-awaited calls are drained before execution ends; unhandled failures become diagnostics.
|
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
|
||||||
|
combinator batches overlap as in normal JavaScript.
|
||||||
|
- [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.
|
||||||
- [x] `try`/`catch` can handle awaited tool and promise failures.
|
- [x] `try`/`catch` can handle awaited tool and promise failures.
|
||||||
- [ ] 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.any`.
|
||||||
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
|
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
|
||||||
- [ ] Custom promise construction with `new Promise(...)`.
|
- [ ] Custom promise construction with `new Promise(...)`.
|
||||||
|
|
@ -269,7 +271,7 @@ ultimate source of truth.
|
||||||
|
|
||||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||||
|
|
||||||
- [ ] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
||||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
- [ ] 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
|
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,17 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
|
||||||
|
|
||||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||||
export type ExecutionLimits = {
|
export type ExecutionLimits = {
|
||||||
/** Maximum wall-clock execution time in milliseconds. No default: absent means no timeout. */
|
/**
|
||||||
|
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
|
||||||
|
* waits for tool interruption cleanup. No default: absent means no timeout.
|
||||||
|
*/
|
||||||
readonly timeoutMs?: number
|
readonly timeoutMs?: number
|
||||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||||
readonly maxToolCalls?: number
|
readonly maxToolCalls?: number
|
||||||
/** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
readonly maxOutputBytes?: number
|
readonly maxOutputBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,8 +81,9 @@ export const DiagnosticKind = Schema.Literals([
|
||||||
"TimeoutExceeded",
|
"TimeoutExceeded",
|
||||||
"ToolFailure",
|
"ToolFailure",
|
||||||
"ExecutionFailure",
|
"ExecutionFailure",
|
||||||
|
"Truncated",
|
||||||
])
|
])
|
||||||
/** Stable categories produced by program, schema, tool, and limit failures. */
|
/** Stable categories produced by program, schema, tool, limit, and truncation diagnostics. */
|
||||||
export type DiagnosticKind = typeof DiagnosticKind.Type
|
export type DiagnosticKind = typeof DiagnosticKind.Type
|
||||||
|
|
||||||
export const Diagnostic = Schema.Struct({
|
export const Diagnostic = Schema.Struct({
|
||||||
|
|
@ -92,6 +99,8 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
||||||
export const Success = Schema.Struct({
|
export const Success = Schema.Struct({
|
||||||
ok: Schema.Literal(true),
|
ok: Schema.Literal(true),
|
||||||
value: Schema.Json,
|
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)),
|
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||||
truncated: Schema.optionalKey(Schema.Boolean),
|
truncated: Schema.optionalKey(Schema.Boolean),
|
||||||
toolCalls: Schema.Array(ToolCallSchema),
|
toolCalls: Schema.Array(ToolCallSchema),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { parse } from "acorn"
|
import { parse } from "acorn"
|
||||||
import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
|
import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
|
||||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||||
import {
|
import {
|
||||||
copyIn,
|
copyIn,
|
||||||
|
|
@ -219,7 +219,7 @@ const normalizeError = (error: unknown): Diagnostic => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
|
// Shared by catch bindings and Promise.allSettled rejection reasons.
|
||||||
const caughtErrorValue = (thrown: unknown): unknown => {
|
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||||
if (thrown instanceof ProgramThrow) return thrown.value
|
if (thrown instanceof ProgramThrow) return thrown.value
|
||||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||||
|
|
@ -611,6 +611,80 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Promise work lives until the program returns, while observation only controls whether a
|
||||||
|
// settled rejection is reported. Neither extends execution: completion interrupts all work.
|
||||||
|
class PromiseRuntime<R> {
|
||||||
|
private readonly active = new Set<SandboxPromise>()
|
||||||
|
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||||
|
private readonly observed = new WeakSet<SandboxPromise>()
|
||||||
|
private readonly failures = new Map<number, Diagnostic>()
|
||||||
|
private nextID = 0
|
||||||
|
|
||||||
|
constructor(private readonly scope: Scope.Scope) {}
|
||||||
|
|
||||||
|
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||||
|
return Effect.suspend(() => {
|
||||||
|
// Allocated at execution time (not construction) so re-run effects cannot share an id,
|
||||||
|
// and before the fork so diagnostics order by creation: a forked body that immediately
|
||||||
|
// creates promises of its own must sequence after its creator.
|
||||||
|
const id = this.nextID++
|
||||||
|
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||||
|
const promise = new SandboxPromise(fiber)
|
||||||
|
this.active.add(promise)
|
||||||
|
this.ids.set(promise, id)
|
||||||
|
fiber.addObserver((exit) => {
|
||||||
|
this.active.delete(promise)
|
||||||
|
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
|
||||||
|
this.ids.delete(promise)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const failure = normalizeError(Cause.squash(exit.cause))
|
||||||
|
this.failures.set(id, {
|
||||||
|
...failure,
|
||||||
|
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous on purpose: JS makes a promise "handled" the moment a construct takes
|
||||||
|
// responsibility for it (await, or membership in a combinator call), not when the
|
||||||
|
// consuming fiber later runs. Call sites must invoke this at that moment.
|
||||||
|
markObserved(promise: SandboxPromise): void {
|
||||||
|
this.observed.add(promise)
|
||||||
|
const id = this.ids.get(promise)
|
||||||
|
this.ids.delete(promise)
|
||||||
|
if (id !== undefined) this.failures.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure settlement subscription: never re-runs work and never affects rejection reporting.
|
||||||
|
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||||
|
return Fiber.await(promise.fiber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unobserved rejections that already settled, in creation order.
|
||||||
|
diagnostics(): Array<Diagnostic> {
|
||||||
|
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal-completion lifecycle: interrupts everything still running and reports the
|
||||||
|
// rejections that already settled un-awaited. interruptAll signals every fiber
|
||||||
|
// synchronously before awaiting termination, so no straggler can spawn new work between
|
||||||
|
// interrupts; the loop re-checks as a backstop because a straggler can create promises
|
||||||
|
// before its interrupt lands.
|
||||||
|
interrupt(): Effect.Effect<Array<Diagnostic>> {
|
||||||
|
const self = this
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
while (self.active.size > 0) {
|
||||||
|
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
|
||||||
|
}
|
||||||
|
return self.diagnostics()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Interpreter<R> {
|
class Interpreter<R> {
|
||||||
private scopes: Array<Map<string, Binding>>
|
private scopes: Array<Map<string, Binding>>
|
||||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
|
|
@ -620,24 +694,22 @@ class Interpreter<R> {
|
||||||
private readonly logs: Array<string>
|
private readonly logs: Array<string>
|
||||||
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
||||||
private readonly callPermits: Semaphore.Semaphore
|
private readonly callPermits: Semaphore.Semaphore
|
||||||
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
private readonly promises: PromiseRuntime<R>
|
||||||
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
|
||||||
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
|
||||||
private readonly pendingSettlements: Set<SandboxPromise>
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||||
|
promises: PromiseRuntime<R>,
|
||||||
logs: Array<string> = [],
|
logs: Array<string> = [],
|
||||||
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
|
callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY),
|
||||||
) {
|
) {
|
||||||
const globalScope = new Map<string, Binding>()
|
const globalScope = new Map<string, Binding>()
|
||||||
this.scopes = [globalScope]
|
this.scopes = [globalScope]
|
||||||
this.invokeTool = invokeTool
|
this.invokeTool = invokeTool
|
||||||
this.toolKeys = toolKeys
|
this.toolKeys = toolKeys
|
||||||
this.logs = logs
|
this.logs = logs
|
||||||
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
this.callPermits = callPermits
|
||||||
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
|
this.promises = promises
|
||||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||||
|
|
@ -703,36 +775,12 @@ class Interpreter<R> {
|
||||||
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
||||||
// without an explicit await, exactly as in JS.
|
// without an explicit await, exactly as in JS.
|
||||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||||
yield* self.drainPendingSettlements()
|
|
||||||
return value
|
return value
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
|
// Eagerly starts a tool call in the execution's promise scope (so timeout and teardown
|
||||||
// their work completes before the execution ends - mirroring a JS runtime waiting on
|
// interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||||
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
|
|
||||||
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
|
|
||||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
|
||||||
const self = this
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
while (self.pendingSettlements.size > 0) {
|
|
||||||
const promise = self.pendingSettlements.values().next().value
|
|
||||||
if (promise === undefined) break
|
|
||||||
const exit = yield* self.observePromise(promise)
|
|
||||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
|
||||||
const failure = normalizeError(Cause.squash(exit.cause))
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
|
||||||
undefined,
|
|
||||||
failure.kind,
|
|
||||||
["Await promises so failures can be caught and handled."],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eagerly starts a tool call on a supervised child fiber (so the execution timeout and
|
|
||||||
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
|
||||||
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
||||||
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
||||||
private createToolCallPromise(
|
private createToolCallPromise(
|
||||||
|
|
@ -743,46 +791,20 @@ class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||||
return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => {
|
return this.promises.create(effect)
|
||||||
const promise = new SandboxPromise(fiber)
|
|
||||||
this.pendingSettlements.add(promise)
|
|
||||||
return promise
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
|
||||||
// Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
|
|
||||||
// Promise.all([p, p])) never re-runs the underlying call.
|
|
||||||
private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
|
||||||
this.pendingSettlements.delete(promise)
|
|
||||||
return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
|
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
|
||||||
// observes it exactly like a synchronous throw at the await site.
|
// observes it exactly like a synchronous throw at the await site. Settlement is idempotent
|
||||||
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
|
// (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call.
|
||||||
const self = this
|
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
|
||||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
|
const promises = this.promises
|
||||||
}
|
return Effect.suspend(() => {
|
||||||
|
promises.markObserved(promise)
|
||||||
private unwrapPromiseExit(
|
return Effect.flatMap(promises.await(promise), (exit) =>
|
||||||
promise: SandboxPromise | undefined,
|
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||||
exit: Exit.Exit<unknown, unknown>,
|
|
||||||
node?: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown> {
|
|
||||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
|
||||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
|
||||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
|
||||||
// as interruption rather than becoming program-visible data.
|
|
||||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
|
||||||
return Effect.fail(
|
|
||||||
new InterpreterRuntimeError(
|
|
||||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
|
||||||
node,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
return Effect.failCause(exit.cause)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
|
|
@ -1532,7 +1554,7 @@ class Interpreter<R> {
|
||||||
// matching real JS semantics for non-thenables.
|
// matching real JS semantics for non-thenables.
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
|
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "NewExpression":
|
case "NewExpression":
|
||||||
|
|
@ -2199,145 +2221,116 @@ class Interpreter<R> {
|
||||||
|
|
||||||
// Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable
|
// Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable
|
||||||
// collection) mixing promise values and plain data - built inline, beforehand, via spread,
|
// collection) mixing promise values and plain data - built inline, beforehand, via spread,
|
||||||
// whatever - because tool calls already run eagerly on their own fibers; the combinators
|
// whatever - because tool calls already run eagerly on their own fibers. Each combinator
|
||||||
// only observe settlements. Joining is therefore sequential (no extra fibers) without
|
// returns a real promise whose join runs on its own scope-owned fiber, observing member
|
||||||
// costing parallelism, and the concurrency cap stays where the work is: the fork semaphore.
|
// settlements; the concurrency cap stays where the work is: the fork semaphore.
|
||||||
private invokePromiseMethod(
|
private invokePromiseMethod(
|
||||||
ref: PromiseMethodReference,
|
ref: PromiseMethodReference,
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
): Effect.Effect<unknown, unknown, R> {
|
): Effect.Effect<unknown, unknown, R> {
|
||||||
const self = this
|
|
||||||
if (ref.name === "resolve") {
|
if (ref.name === "resolve") {
|
||||||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||||
// promise already fulfilled with the value.
|
// promise already fulfilled with the value. Pre-settled values still fork a scope-owned
|
||||||
|
// fiber so every promise shares one lifecycle (an abandoned reject is reported, teardown
|
||||||
|
// is uniform).
|
||||||
const value = args[0]
|
const value = args[0]
|
||||||
return Effect.succeed(
|
return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value))
|
||||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (ref.name === "reject") {
|
if (ref.name === "reject") {
|
||||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||||
if (items === undefined) {
|
if (items === undefined) {
|
||||||
throw new InterpreterRuntimeError(
|
return this.createPromise(
|
||||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
Effect.fail(
|
||||||
node,
|
new InterpreterRuntimeError(
|
||||||
|
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||||
|
node,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS makes combinator members "handled" synchronously at the call - their rejections
|
||||||
|
// belong to the aggregate from this moment, even ones settling before it runs.
|
||||||
|
for (const item of items) {
|
||||||
|
if (item instanceof SandboxPromise) this.promises.markObserved(item)
|
||||||
|
}
|
||||||
|
|
||||||
switch (ref.name) {
|
switch (ref.name) {
|
||||||
case "all": {
|
case "all": {
|
||||||
// Mark every promise element observed up-front (Promise.all handles all of its
|
// Each observation re-raises its member's failure, so Effect.all rejects on the first
|
||||||
// members' failures, as in JS), race their settlements for fail-fast rejection, and
|
// failure without waiting for the rest and preserves input order when all fulfill.
|
||||||
// preserve input order when they all fulfill. Rejected calls keep draining siblings.
|
// Its failure-time interruption only unsubscribes the sibling waiters: the underlying
|
||||||
const observations = items.map((item, index) =>
|
// fibers stay execution-owned and keep running, as in JS.
|
||||||
|
const observations = items.map((item) =>
|
||||||
item instanceof SandboxPromise
|
item instanceof SandboxPromise
|
||||||
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
|
? Effect.flatMap(this.promises.await(item), (exit) =>
|
||||||
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||||
|
)
|
||||||
|
: Effect.succeed(item),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return this.createPromise(Effect.all(observations, { concurrency: "unbounded" }))
|
||||||
const remaining = [...observations]
|
|
||||||
const values: Array<unknown> = []
|
|
||||||
values.length = items.length
|
|
||||||
while (remaining.length > 0) {
|
|
||||||
const winner = yield* Effect.raceAll(remaining)
|
|
||||||
const position = remaining.indexOf(observations[winner.index])
|
|
||||||
if (position >= 0) remaining.splice(position, 1)
|
|
||||||
if (Exit.isSuccess(winner.exit)) {
|
|
||||||
values[winner.index] = winner.exit.value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
yield* self.createPromise(
|
|
||||||
Effect.asVoid(
|
|
||||||
Effect.forEach(
|
|
||||||
items,
|
|
||||||
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
case "allSettled": {
|
case "allSettled": {
|
||||||
const observations = items.map((item) =>
|
const observations = items.map((item) =>
|
||||||
item instanceof SandboxPromise
|
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
|
||||||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
|
||||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return this.createPromise(
|
||||||
const outcomes: Array<unknown> = []
|
Effect.gen(function* () {
|
||||||
for (const observation of observations) {
|
const outcomes: Array<unknown> = []
|
||||||
const { exit, promise } = yield* observation
|
for (const observation of observations) {
|
||||||
if (Exit.isSuccess(exit)) {
|
const exit = yield* observation
|
||||||
outcomes.push(
|
if (Exit.isSuccess(exit)) {
|
||||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
outcomes.push(
|
||||||
)
|
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||||
continue
|
|
||||||
}
|
|
||||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
|
||||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
|
||||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
|
||||||
return yield* Effect.failCause(exit.cause)
|
|
||||||
}
|
|
||||||
const thrown = raceInterrupted
|
|
||||||
? new InterpreterRuntimeError(
|
|
||||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
|
||||||
node,
|
|
||||||
)
|
)
|
||||||
: Cause.squash(exit.cause)
|
continue
|
||||||
outcomes.push(
|
}
|
||||||
Object.assign(Object.create(null) as SafeObject, {
|
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||||
status: "rejected",
|
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||||
reason: caughtErrorValue(thrown),
|
return yield* Effect.failCause(exit.cause)
|
||||||
}),
|
}
|
||||||
)
|
outcomes.push(
|
||||||
}
|
Object.assign(Object.create(null) as SafeObject, {
|
||||||
return outcomes
|
status: "rejected",
|
||||||
})
|
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
case "race": {
|
case "race": {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
throw new InterpreterRuntimeError(
|
return this.createPromise(
|
||||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
Effect.fail(
|
||||||
node,
|
new InterpreterRuntimeError(
|
||||||
|
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||||
|
node,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const observations = items.map((item, index) =>
|
const observations = items.map((item) =>
|
||||||
item instanceof SandboxPromise
|
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
|
||||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
)
|
||||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
// First settlement (fulfilled OR rejected) wins; losing work stays execution-owned
|
||||||
|
// and is interrupted at normal completion (already observed) or by teardown.
|
||||||
|
return this.createPromise(
|
||||||
|
Effect.flatMap(Effect.raceAll(observations), (exit) =>
|
||||||
|
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
|
||||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
|
||||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
|
||||||
const winner = yield* Effect.raceAll(observations)
|
|
||||||
for (const [index, item] of items.entries()) {
|
|
||||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
|
||||||
item.interrupted = true
|
|
||||||
yield* Fiber.interrupt(item.fiber)
|
|
||||||
}
|
|
||||||
const winningItem = items[winner.index]
|
|
||||||
return yield* self.unwrapPromiseExit(
|
|
||||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
|
||||||
winner.exit,
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||||
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, {
|
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
|
||||||
callPermits: this.callPermits,
|
|
||||||
pendingSettlements: this.pendingSettlements,
|
|
||||||
})
|
|
||||||
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||||
const run = Effect.gen(function* () {
|
const run = Effect.gen(function* () {
|
||||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||||
|
|
@ -3484,68 +3477,109 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||||
limits: ResolvedExecutionLimits,
|
limits: ResolvedExecutionLimits,
|
||||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||||
const hooks = {
|
|
||||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
|
||||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
|
||||||
}
|
|
||||||
const tools = ToolRuntime.make(
|
|
||||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
|
||||||
limits.maxToolCalls,
|
|
||||||
searchIndex,
|
|
||||||
hooks,
|
|
||||||
)
|
|
||||||
const logs: Array<string> = []
|
|
||||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
|
||||||
|
|
||||||
if (options.code.trim().length === 0) {
|
if (options.code.trim().length === 0) {
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||||
toolCalls: tools.calls,
|
toolCalls: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const operation = Effect.gen(function* () {
|
// Suspended so all per-execution state - tool-call admission budget and audit list, logs,
|
||||||
const program = parseProgram(options.code)
|
// and the timeout path's completed value - binds at run time: a reused Effect must start
|
||||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
// from a clean slate instead of observing a previous run's state.
|
||||||
const value = yield* interpreter.run(program)
|
return Effect.suspend(() => {
|
||||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
const hooks = {
|
||||||
return {
|
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||||
ok: true,
|
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||||
value: result,
|
}
|
||||||
...logged(),
|
const tools = ToolRuntime.make(
|
||||||
toolCalls: tools.calls,
|
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||||
} satisfies Result
|
limits.maxToolCalls,
|
||||||
}).pipe((program) => {
|
searchIndex,
|
||||||
const timeoutMs = limits.timeoutMs
|
hooks,
|
||||||
if (timeoutMs === undefined) return program
|
)
|
||||||
return program.pipe(
|
const logs: Array<string> = []
|
||||||
Effect.timeoutOrElse({
|
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||||
duration: timeoutMs,
|
// Set once the program body returned and its value crossed the data boundary, so a timeout
|
||||||
orElse: () =>
|
// firing during leftover interruption reports "completed with interrupted background work"
|
||||||
Effect.succeed({
|
// instead of discarding the computed value as a plain timeout.
|
||||||
ok: false,
|
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
|
||||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
|
||||||
|
const base = Effect.acquireUseRelease(
|
||||||
|
Scope.make("parallel"),
|
||||||
|
(scope) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const program = parseProgram(options.code)
|
||||||
|
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||||
|
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
|
||||||
|
const value = yield* interpreter.run(program)
|
||||||
|
// Validate the result first so an invalid value is a fatal completion that closes
|
||||||
|
// the promise scope directly instead of taking the normal-completion path.
|
||||||
|
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||||
|
returned = { value: result, promises }
|
||||||
|
const warnings = yield* promises.interrupt()
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: result,
|
||||||
|
...(warnings.length > 0 ? { warnings } : {}),
|
||||||
...logged(),
|
...logged(),
|
||||||
toolCalls: tools.calls,
|
toolCalls: tools.calls,
|
||||||
} satisfies Result),
|
} satisfies Result
|
||||||
}),
|
}),
|
||||||
|
(scope, exit) => Scope.close(scope, exit),
|
||||||
|
)
|
||||||
|
const timeoutMs = limits.timeoutMs
|
||||||
|
const operation =
|
||||||
|
timeoutMs === undefined
|
||||||
|
? base
|
||||||
|
: base.pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: timeoutMs,
|
||||||
|
orElse: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (returned === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||||
|
...logged(),
|
||||||
|
toolCalls: tools.calls,
|
||||||
|
} satisfies Result
|
||||||
|
}
|
||||||
|
// The timeout warning leads so byte-budget truncation cuts it last.
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: returned.value,
|
||||||
|
warnings: [
|
||||||
|
{
|
||||||
|
kind: "TimeoutExceeded",
|
||||||
|
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
|
||||||
|
},
|
||||||
|
...returned.promises.diagnostics(),
|
||||||
|
],
|
||||||
|
...logged(),
|
||||||
|
toolCalls: tools.calls,
|
||||||
|
} satisfies Result
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return operation.pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Cause.hasInterruptsOnly(cause)
|
||||||
|
? Effect.interrupt
|
||||||
|
: Effect.succeed({
|
||||||
|
ok: false,
|
||||||
|
error: normalizeError(Cause.squash(cause)),
|
||||||
|
...logged(),
|
||||||
|
toolCalls: tools.calls,
|
||||||
|
} satisfies Result),
|
||||||
|
),
|
||||||
|
Effect.map((result) =>
|
||||||
|
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return operation.pipe(
|
|
||||||
Effect.catchCause((cause) =>
|
|
||||||
Cause.hasInterruptsOnly(cause)
|
|
||||||
? Effect.interrupt
|
|
||||||
: Effect.succeed({
|
|
||||||
ok: false,
|
|
||||||
error: normalizeError(Cause.squash(cause)),
|
|
||||||
...logged(),
|
|
||||||
toolCalls: tools.calls,
|
|
||||||
} satisfies Result),
|
|
||||||
),
|
|
||||||
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
||||||
|
|
@ -3560,9 +3594,10 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
|
* Bounds retained program payload bytes (serialized result value and logs) to `maxOutputBytes`.
|
||||||
* Oversized values are replaced by their truncated serialized text with an explanatory marker,
|
* Warning diagnostics are bounded by a separate budget of the same size so a large value can
|
||||||
* and logs are kept from the start until the remaining budget is exhausted. Truncation never
|
* never starve runtime-authored diagnostics. Fixed truncation notices are added outside those
|
||||||
|
* budgets, as is any framing added when a host renders the structured result. Truncation never
|
||||||
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
|
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
|
||||||
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
|
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
|
||||||
*/
|
*/
|
||||||
|
|
@ -3584,6 +3619,23 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnings = result.ok ? (result.warnings ?? []) : []
|
||||||
|
const keptWarnings: Array<Diagnostic> = []
|
||||||
|
let warningBytes = 0
|
||||||
|
for (const warning of warnings) {
|
||||||
|
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
|
||||||
|
if (warningBytes + bytes > maxOutputBytes) break
|
||||||
|
warningBytes += bytes
|
||||||
|
keptWarnings.push(warning)
|
||||||
|
}
|
||||||
|
if (keptWarnings.length < warnings.length) {
|
||||||
|
truncated = true
|
||||||
|
keptWarnings.push({
|
||||||
|
kind: "Truncated",
|
||||||
|
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const logs = result.logs ?? []
|
const logs = result.logs ?? []
|
||||||
const kept: Array<string> = []
|
const kept: Array<string> = []
|
||||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||||
|
|
@ -3600,8 +3652,16 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!truncated) return result
|
if (!truncated) return result
|
||||||
|
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
|
||||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||||
return result.ok
|
return result.ok
|
||||||
? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
? {
|
||||||
|
ok: true,
|
||||||
|
value,
|
||||||
|
...warningsPart,
|
||||||
|
...logsPart,
|
||||||
|
truncated: true,
|
||||||
|
toolCalls: result.toolCalls,
|
||||||
|
}
|
||||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,7 @@ 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.",
|
"- 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.",
|
"- 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.',
|
'- 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.",
|
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||||
...(complete
|
...(complete
|
||||||
? []
|
? []
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import type { Effect, Fiber } from "effect"
|
import type { Fiber } from "effect"
|
||||||
|
|
||||||
export class SandboxPromise {
|
export class SandboxPromise {
|
||||||
interrupted = false
|
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
|
||||||
constructor(
|
|
||||||
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
|
|
||||||
readonly immediate?: Effect.Effect<unknown, unknown>,
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SandboxDate {
|
export class SandboxDate {
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,26 @@ describe("CodeMode public contract", () => {
|
||||||
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
|
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 () => {
|
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
|
||||||
const runtime = CodeMode.make({ tools })
|
const runtime = CodeMode.make({ tools })
|
||||||
expect(runtime.catalog()).toStrictEqual([
|
expect(runtime.catalog()).toStrictEqual([
|
||||||
|
|
|
||||||
906
packages/codemode/test/promise-test262.test.ts
Normal file
906
packages/codemode/test/promise-test262.test.ts
Normal file
|
|
@ -0,0 +1,906 @@
|
||||||
|
/*
|
||||||
|
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75.
|
||||||
|
* Every test names its upstream source; test.failing cases are executable conformance
|
||||||
|
* targets for intended Promise behavior that CodeMode does not implement yet.
|
||||||
|
*
|
||||||
|
* Copyright 2014 Cubane Canada, Inc. All rights reserved.
|
||||||
|
* Copyright 2015 Microsoft Corporation. All rights reserved.
|
||||||
|
* Copyright 2016 Microsoft, Inc. All rights reserved.
|
||||||
|
* Copyright 2017 Caitlin Potter. All rights reserved.
|
||||||
|
* Copyright (C) 2016-2020 the V8 project authors. All rights reserved.
|
||||||
|
* Copyright (C) 2018-2020 Rick Waldron. All rights reserved.
|
||||||
|
* Copyright (C) 2019 Leo Balter. All rights reserved.
|
||||||
|
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { CodeMode } from "../src/index.js"
|
||||||
|
|
||||||
|
const execute = (code: string) =>
|
||||||
|
Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
|
||||||
|
|
||||||
|
const value = async (code: string) => {
|
||||||
|
const result = await execute(code)
|
||||||
|
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Test262 Promise statics", () => {
|
||||||
|
test("statics are callable and return promises", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A1.1_T1.js
|
||||||
|
// test/built-ins/Promise/allSettled/is-function.js
|
||||||
|
// test/built-ins/Promise/allSettled/returns-promise.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A1.1_T1.js
|
||||||
|
// test/built-ins/Promise/resolve/S25.4.4.5_A1.1_T1.js
|
||||||
|
// test/built-ins/Promise/reject/S25.4.4.4_A1.1_T1.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const values = [
|
||||||
|
Promise.all([]),
|
||||||
|
Promise.allSettled([]),
|
||||||
|
Promise.race([undefined]),
|
||||||
|
Promise.resolve(),
|
||||||
|
Promise.reject(),
|
||||||
|
]
|
||||||
|
const callable = [
|
||||||
|
typeof Promise.all,
|
||||||
|
typeof Promise.allSettled,
|
||||||
|
typeof Promise.race,
|
||||||
|
typeof Promise.resolve,
|
||||||
|
typeof Promise.reject,
|
||||||
|
]
|
||||||
|
try { await values[4] } catch {}
|
||||||
|
return [callable, values.map((item) => item instanceof Promise)]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["function", "function", "function", "function", "function"],
|
||||||
|
[true, true, true, true, true],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.all returns fresh arrays for empty and settled inputs", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A2.1_T1.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T2.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T3.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A7.1_T1.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const input = []
|
||||||
|
const emptyPromise = Promise.all(input)
|
||||||
|
const empty = await emptyPromise
|
||||||
|
const onePromise = Promise.all([Promise.resolve(3)])
|
||||||
|
const one = await onePromise
|
||||||
|
return [
|
||||||
|
emptyPromise instanceof Promise,
|
||||||
|
empty instanceof Array,
|
||||||
|
empty.length,
|
||||||
|
empty !== input,
|
||||||
|
onePromise instanceof Promise,
|
||||||
|
one instanceof Array,
|
||||||
|
one.length,
|
||||||
|
one[0],
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([true, true, 0, true, true, true, 1, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.all adopts values and preserves input order and identity", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/resolve-non-thenable.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A8.2_T1.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A8.2_T2.js
|
||||||
|
const result = await value(`
|
||||||
|
const first = { id: 1 }
|
||||||
|
const second = { id: 2 }
|
||||||
|
const values = await Promise.all([Promise.resolve(3), first, Promise.resolve(second)])
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { await promise; return "fulfilled" } catch (reason) { return reason }
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
values.length,
|
||||||
|
values[0],
|
||||||
|
values[1] === first,
|
||||||
|
values[2] === second,
|
||||||
|
await observe(Promise.all([Promise.reject(1), Promise.resolve(2)])),
|
||||||
|
await observe(Promise.all([Promise.resolve(1), Promise.reject(2)])),
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
expect(result).toEqual([3, 3, true, true, 1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.allSettled returns fresh arrays and ordered outcome records", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/allSettled/resolves-empty-array.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolves-to-array.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-all-fulfilled.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-all-rejected.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-all-mixed.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolve-non-thenable.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const input = []
|
||||||
|
const empty = await Promise.allSettled(input)
|
||||||
|
const reason = { id: 4 }
|
||||||
|
const object = { id: 5 }
|
||||||
|
const outcomes = await Promise.allSettled([
|
||||||
|
Promise.resolve(1),
|
||||||
|
Promise.reject(2),
|
||||||
|
3,
|
||||||
|
Promise.reject(reason),
|
||||||
|
object,
|
||||||
|
])
|
||||||
|
return [
|
||||||
|
empty instanceof Array,
|
||||||
|
empty.length,
|
||||||
|
empty !== input,
|
||||||
|
outcomes,
|
||||||
|
outcomes[4].value === object,
|
||||||
|
outcomes.map((item) => Object.keys(item)),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
{ status: "fulfilled", value: 1 },
|
||||||
|
{ status: "rejected", reason: 2 },
|
||||||
|
{ status: "fulfilled", value: 3 },
|
||||||
|
{ status: "rejected", reason: { id: 4 } },
|
||||||
|
{ status: "fulfilled", value: { id: 5 } },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
["status", "value"],
|
||||||
|
["status", "reason"],
|
||||||
|
["status", "value"],
|
||||||
|
["status", "reason"],
|
||||||
|
["status", "value"],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.race preserves fulfillment, rejection, and iterable order", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A6.2_T1.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A7.1_T1.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A7.2_T1.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A7.3_T1.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A7.3_T2.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
return await Promise.all([
|
||||||
|
observe(Promise.race([23])),
|
||||||
|
observe(Promise.race([Promise.reject(7)])),
|
||||||
|
observe(Promise.race([Promise.resolve(1), Promise.resolve(2)])),
|
||||||
|
observe(Promise.race([Promise.reject(3), Promise.resolve(4)])),
|
||||||
|
])
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["fulfilled", 23],
|
||||||
|
["rejected", 7],
|
||||||
|
["fulfilled", 1],
|
||||||
|
["rejected", 3],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("combinators consume supported string iterables", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/iter-arg-is-string-resolve.js
|
||||||
|
// test/built-ins/Promise/allSettled/iter-arg-is-string-resolve.js
|
||||||
|
// test/built-ins/Promise/race/iter-arg-is-string-resolve.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
return [
|
||||||
|
await Promise.all("abc"),
|
||||||
|
await Promise.allSettled("ab"),
|
||||||
|
await Promise.race("abc"),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["a", "b", "c"],
|
||||||
|
[
|
||||||
|
{ status: "fulfilled", value: "a" },
|
||||||
|
{ status: "fulfilled", value: "b" },
|
||||||
|
],
|
||||||
|
"a",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
|
||||||
|
// test/built-ins/Promise/resolve/resolve-non-obj.js
|
||||||
|
// test/built-ins/Promise/resolve/resolve-non-thenable.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const object = { id: 1 }
|
||||||
|
const promise = Promise.resolve(1)
|
||||||
|
return [
|
||||||
|
await Promise.resolve(23),
|
||||||
|
await Promise.resolve(Promise.resolve(24)),
|
||||||
|
(await Promise.resolve(object)) === object,
|
||||||
|
[promise].includes(Promise.resolve(promise)),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([23, 24, true, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.reject preserves primitive and object reasons", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/reject/S25.4.4.4_A2.1_T1.js
|
||||||
|
const result = await value(`
|
||||||
|
const object = { reason: true }
|
||||||
|
const reasons = [undefined, null, false, true, 0, "", 42, object]
|
||||||
|
const observe = async (reason) => {
|
||||||
|
try { await Promise.reject(reason); return false } catch (caught) { return caught === reason }
|
||||||
|
}
|
||||||
|
return await Promise.all(reasons.map(observe))
|
||||||
|
`)
|
||||||
|
expect(result).toEqual([true, true, true, true, true, true, true, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.all resolves duplicate members into every slot", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/invoke-resolve-on-promises-every-iteration-of-promise.js
|
||||||
|
// test/built-ins/Promise/all/invoke-resolve-on-values-every-iteration-of-promise.js
|
||||||
|
// (adapted: CodeMode has no observable Promise.resolve hook, so per-iteration
|
||||||
|
// handling of a repeated member is asserted through the resolved slots)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const settled = Promise.resolve(3)
|
||||||
|
const computed = (async () => "computed")()
|
||||||
|
return [
|
||||||
|
await Promise.all([settled, settled, settled]),
|
||||||
|
await Promise.all([computed, "plain", computed]),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
[3, 3, 3],
|
||||||
|
["computed", "plain", "computed"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.allSettled records duplicate members independently", async () => {
|
||||||
|
// Source: test/built-ins/Promise/allSettled/invoke-resolve-on-promises-every-iteration-of-promise.js
|
||||||
|
// (adapted: per-iteration handling of a repeated member is asserted through the
|
||||||
|
// outcome records instead of a Promise.resolve hook)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const good = Promise.resolve(1)
|
||||||
|
const bad = Promise.reject(2)
|
||||||
|
return await Promise.allSettled([good, bad, good, bad])
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ status: "fulfilled", value: 1 },
|
||||||
|
{ status: "rejected", reason: 2 },
|
||||||
|
{ status: "fulfilled", value: 1 },
|
||||||
|
{ status: "rejected", reason: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("combinators adopt members that settled before the call", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/reject-immed.js
|
||||||
|
// test/built-ins/Promise/allSettled/reject-immed.js
|
||||||
|
// test/built-ins/Promise/race/reject-immed.js
|
||||||
|
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
|
||||||
|
// and were even observed, before the combinator call)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const fulfilled = Promise.resolve("done")
|
||||||
|
const rejected = Promise.reject("failed")
|
||||||
|
try { await rejected } catch {}
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
await observe(Promise.all([fulfilled, rejected])),
|
||||||
|
await Promise.allSettled([rejected, fulfilled]),
|
||||||
|
await observe(Promise.race([rejected, fulfilled])),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["rejected", "failed"],
|
||||||
|
[
|
||||||
|
{ status: "rejected", reason: "failed" },
|
||||||
|
{ status: "fulfilled", value: "done" },
|
||||||
|
],
|
||||||
|
["rejected", "failed"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("combinator results follow input order, not settlement order", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/resolve-non-thenable.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-all-mixed.js
|
||||||
|
// (adapted: members are created, and therefore settle, in reverse of input order;
|
||||||
|
// deferred settlement is not expressible without host-async work in this corpus)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const third = Promise.resolve("c")
|
||||||
|
const failing = (async () => { throw "b" })()
|
||||||
|
try { await failing } catch {}
|
||||||
|
const second = (async () => "b")()
|
||||||
|
const first = Promise.resolve("a")
|
||||||
|
return [
|
||||||
|
await Promise.all([first, second, third]),
|
||||||
|
await Promise.allSettled([first, failing, third]),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["a", "b", "c"],
|
||||||
|
[
|
||||||
|
{ status: "fulfilled", value: "a" },
|
||||||
|
{ status: "rejected", reason: "b" },
|
||||||
|
{ status: "fulfilled", value: "c" },
|
||||||
|
],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.race ignores a rejected loser once the first contender wins", async () => {
|
||||||
|
// Source: test/built-ins/Promise/race/reject-ignored-immed.js
|
||||||
|
// (adapted: the losing rejection comes from an async function instead of a thenable;
|
||||||
|
// the exact-equality check also asserts the loser leaves no unhandled-rejection warning)
|
||||||
|
expect(
|
||||||
|
await execute(`
|
||||||
|
const loser = (async () => { throw "lost" })()
|
||||||
|
return await Promise.race([Promise.resolve("won"), loser])
|
||||||
|
`),
|
||||||
|
).toEqual({ ok: true, value: "won", toolCalls: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.race([]) returns a promise whose CodeMode failure is catchable", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
|
||||||
|
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
|
||||||
|
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
|
||||||
|
// divergence rather than the spec never-settles behavior)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const empty = Promise.race([])
|
||||||
|
try {
|
||||||
|
await empty
|
||||||
|
return "settled"
|
||||||
|
} catch (error) {
|
||||||
|
return [empty instanceof Promise, error instanceof Error]
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toEqual([true, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
|
||||||
|
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
|
||||||
|
// (adapted: no executor construction, and identity is observed with Array includes
|
||||||
|
// because promises are not comparable data values in CodeMode)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const promise = Promise.resolve({ id: 1 })
|
||||||
|
return [
|
||||||
|
[promise].includes(Promise.resolve(promise)),
|
||||||
|
[promise].includes(Promise.resolve(Promise.resolve(promise))),
|
||||||
|
(await Promise.resolve(Promise.resolve(promise))).id,
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([true, true, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.resolve of a rejected promise preserves identity and reason", async () => {
|
||||||
|
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.3_T1.js
|
||||||
|
// (adapted: the source promise is already rejected instead of rejected later)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const rejected = Promise.reject("oops")
|
||||||
|
const adopted = Promise.resolve(rejected)
|
||||||
|
const identity = [rejected].includes(adopted)
|
||||||
|
try {
|
||||||
|
await adopted
|
||||||
|
return "fulfilled"
|
||||||
|
} catch (reason) {
|
||||||
|
return [identity, reason]
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toEqual([true, "oops"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Promise.reject uses a promise reason without flattening it", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/reject-via-fn-immed.js
|
||||||
|
// test/built-ins/Promise/reject-via-fn-deferred.js
|
||||||
|
// (adapted: the promise reason goes through Promise.reject instead of executor reject)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (reason) => {
|
||||||
|
try {
|
||||||
|
await Promise.reject(reason)
|
||||||
|
return "fulfilled"
|
||||||
|
} catch (caught) {
|
||||||
|
const identity = [reason].includes(caught)
|
||||||
|
try { return [identity, caught instanceof Promise, await caught] }
|
||||||
|
catch (inner) { return [identity, caught instanceof Promise, "rethrew " + inner] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [await observe(Promise.resolve(1)), await observe(Promise.reject("inner"))]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
[true, true, 1],
|
||||||
|
[true, true, "rethrew inner"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Test262 async functions and await", () => {
|
||||||
|
test("declaration, expression, and arrow forms return promises", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/language/statements/async-function/declaration-returns-promise.js
|
||||||
|
// test/language/expressions/async-function/expression-returns-promise.js
|
||||||
|
// test/language/expressions/async-arrow-function/arrow-returns-promise.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
async function declaration() { return 1 }
|
||||||
|
const expression = async function() { return 2 }
|
||||||
|
const arrow = async () => 3
|
||||||
|
const promises = [declaration(), expression(), arrow()]
|
||||||
|
return [promises.map((item) => item instanceof Promise), await Promise.all(promises)]
|
||||||
|
`),
|
||||||
|
).toEqual([[true, true, true], [1, 2, 3]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("async bodies adopt returns and reject throws before and after await", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/language/statements/async-function/evaluation-body.js
|
||||||
|
// test/language/statements/async-function/evaluation-body-that-returns.js
|
||||||
|
// test/language/statements/async-function/evaluation-body-that-returns-after-await.js
|
||||||
|
// test/language/statements/async-function/evaluation-body-that-throws.js
|
||||||
|
// test/language/statements/async-function/evaluation-body-that-throws-after-await.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const order = []
|
||||||
|
const plain = async () => { order.push("body"); return 42 }
|
||||||
|
const afterAwait = async () => { await Promise.resolve(); return 43 }
|
||||||
|
const throwsBefore = async () => { throw 1 }
|
||||||
|
const throwsAfter = async () => { await Promise.resolve(); throw 2 }
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
const first = plain()
|
||||||
|
return [
|
||||||
|
order,
|
||||||
|
await observe(first),
|
||||||
|
await observe(afterAwait()),
|
||||||
|
await observe(throwsBefore()),
|
||||||
|
await observe(throwsAfter()),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["body"],
|
||||||
|
["fulfilled", 42],
|
||||||
|
["fulfilled", 43],
|
||||||
|
["rejected", 1],
|
||||||
|
["rejected", 2],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default-parameter throws reject instead of escaping the call", async () => {
|
||||||
|
// Source: test/language/statements/async-function/evaluation-default-that-throws.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const fail = () => { throw new Error("default") }
|
||||||
|
const run = async (value = fail()) => value
|
||||||
|
let returned = false
|
||||||
|
try {
|
||||||
|
const promise = run()
|
||||||
|
returned = promise instanceof Promise
|
||||||
|
await promise
|
||||||
|
return [returned, "fulfilled"]
|
||||||
|
} catch (error) {
|
||||||
|
return [returned, error.message]
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toEqual([true, "default"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("async try/finally completion records override earlier completion", async () => {
|
||||||
|
// Sources: the try-{return,throw,reject}-finally-{return,throw,reject}.js matrix under
|
||||||
|
// test/language/statements/async-function, test/language/expressions/async-function,
|
||||||
|
// and test/language/expressions/async-arrow-function.
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
const returnReturn = async () => { try { return "early" } finally { return await Promise.resolve("override") } }
|
||||||
|
const returnThrow = async () => { try { return "early" } finally { throw "override" } }
|
||||||
|
const returnReject = async () => { try { return "early" } finally { await Promise.reject("override") } }
|
||||||
|
const throwReturn = async () => { try { throw "early" } finally { return await Promise.resolve("override") } }
|
||||||
|
const throwThrow = async () => { try { throw "early" } finally { throw "override" } }
|
||||||
|
const throwReject = async () => { try { throw "early" } finally { await Promise.reject("override") } }
|
||||||
|
const rejectReturn = async () => { try { await Promise.reject("early") } finally { return await Promise.resolve("override") } }
|
||||||
|
const rejectThrow = async () => { try { await Promise.reject("early") } finally { throw "override" } }
|
||||||
|
const rejectReject = async () => { try { await Promise.reject("early") } finally { await Promise.reject("override") } }
|
||||||
|
return await Promise.all([
|
||||||
|
observe(returnReturn()), observe(returnThrow()), observe(returnReject()),
|
||||||
|
observe(throwReturn()), observe(throwThrow()), observe(throwReject()),
|
||||||
|
observe(rejectReturn()), observe(rejectThrow()), observe(rejectReject()),
|
||||||
|
])
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["fulfilled", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
["fulfilled", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
["fulfilled", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
["rejected", "override"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("await preserves an object whose then property is not callable", async () => {
|
||||||
|
// Source: test/language/expressions/await/await-awaits-thenable-not-callable.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const thenable = { then: 42 }
|
||||||
|
return (await thenable) === thenable
|
||||||
|
`),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("await returns non-promise operands unchanged", async () => {
|
||||||
|
// Source: test/language/expressions/await/await-non-promise.js
|
||||||
|
// (adapted: only value pass-through is asserted here; the spec tick ordering around
|
||||||
|
// await of non-promises is covered by the failing interleaving test below)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const object = { id: 1 }
|
||||||
|
const array = [1, 2]
|
||||||
|
return [
|
||||||
|
await 1,
|
||||||
|
await "text",
|
||||||
|
await true,
|
||||||
|
(await null) === null,
|
||||||
|
(await undefined) === undefined,
|
||||||
|
(await object) === object,
|
||||||
|
(await array) === array,
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([1, "text", true, true, true, true, true])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Test262 expected Promise conformance", () => {
|
||||||
|
for (const name of ["all", "allSettled", "race"] as const) {
|
||||||
|
test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js
|
||||||
|
// test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js
|
||||||
|
// test/built-ins/Promise/race/iter-arg-is-number-reject.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
try {
|
||||||
|
const promise = Promise.${name}(42)
|
||||||
|
const returned = promise instanceof Promise
|
||||||
|
await promise
|
||||||
|
return [returned, "fulfilled"]
|
||||||
|
} catch (error) {
|
||||||
|
return [true, error.name]
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toEqual([true, "TypeError"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.failing("Promise.all consumes sparse positions as undefined", async () => {
|
||||||
|
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const input = []
|
||||||
|
input[1] = 1
|
||||||
|
const result = await Promise.all(input)
|
||||||
|
return [result.length, result[0] === undefined, result[1]]
|
||||||
|
`),
|
||||||
|
).toEqual([2, true, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.allSettled consumes sparse positions as undefined", async () => {
|
||||||
|
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const input = []
|
||||||
|
input[1] = 1
|
||||||
|
const result = await Promise.allSettled(input)
|
||||||
|
return [result.length, result[0].status, result[0].value === undefined, result[1]]
|
||||||
|
`),
|
||||||
|
).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.race consumes a sparse first position as undefined", async () => {
|
||||||
|
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const input = []
|
||||||
|
input[1] = 1
|
||||||
|
return (await Promise.race(input)) === undefined
|
||||||
|
`),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.all settles after reactions attached to its inputs", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js
|
||||||
|
// test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = [1]
|
||||||
|
const input = Promise.resolve(1)
|
||||||
|
const aggregate = Promise.all([input])
|
||||||
|
aggregate.then(() => sequence.push(4))
|
||||||
|
input.then(() => sequence.push(3)).then(() => sequence.push(5))
|
||||||
|
sequence.push(2)
|
||||||
|
await aggregate
|
||||||
|
await Promise.resolve()
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-sequence.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-sequence-mixed.js
|
||||||
|
// test/built-ins/Promise/allSettled/resolved-sequence-with-rejections.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = [1]
|
||||||
|
const input = Promise.resolve(1)
|
||||||
|
const aggregate = Promise.allSettled([input])
|
||||||
|
aggregate.then(() => sequence.push(4))
|
||||||
|
input.then(() => sequence.push(3)).then(() => sequence.push(5))
|
||||||
|
sequence.push(2)
|
||||||
|
await aggregate
|
||||||
|
await Promise.resolve()
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.race settles in a reaction after its winning input", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js
|
||||||
|
// test/built-ins/Promise/race/resolved-sequence-extra-ticks.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = [1]
|
||||||
|
const race = Promise.race([1])
|
||||||
|
race.then(() => sequence.push(4))
|
||||||
|
Promise.resolve().then(() => sequence.push(3)).then(() => sequence.push(5))
|
||||||
|
sequence.push(2)
|
||||||
|
await race
|
||||||
|
await Promise.resolve()
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("then reactions route and propagate fulfillment and rejection", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/prototype/then/prfm-fulfilled.js
|
||||||
|
// test/built-ins/Promise/prototype/then/prfm-rejected.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-identity.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-thrower.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-normal.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-rejected-return-normal.js
|
||||||
|
// test/built-ins/Promise/prototype/then/rxn-handler-rejected-return-abrupt.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
return await Promise.all([
|
||||||
|
observe(Promise.resolve(1).then((value) => value + 1)),
|
||||||
|
observe(Promise.reject(2).then(undefined, (reason) => reason + 1)),
|
||||||
|
observe(Promise.resolve(3).then(undefined)),
|
||||||
|
observe(Promise.reject(4).then(undefined)),
|
||||||
|
observe(Promise.resolve(5).then(() => { throw 6 })),
|
||||||
|
observe(Promise.reject(7).then(undefined, () => { throw 8 })),
|
||||||
|
])
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["fulfilled", 2],
|
||||||
|
["fulfilled", 3],
|
||||||
|
["fulfilled", 3],
|
||||||
|
["rejected", 4],
|
||||||
|
["rejected", 6],
|
||||||
|
["rejected", 8],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("then reactions preserve breadth-first queue order", async () => {
|
||||||
|
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = [1]
|
||||||
|
const promise = Promise.resolve()
|
||||||
|
const first = promise.then(() => sequence.push(3)).then(() => sequence.push(5)).then(() => sequence.push(7))
|
||||||
|
const second = promise.then(() => sequence.push(4)).then(() => sequence.push(6)).then(() => sequence.push(8))
|
||||||
|
sequence.push(2)
|
||||||
|
await Promise.all([first, second])
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js
|
||||||
|
// test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js
|
||||||
|
// test/built-ins/Promise/prototype/then/resolve-pending-fulfilled-self.js
|
||||||
|
// test/built-ins/Promise/prototype/then/resolve-pending-rejected-self.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { await promise; return "fulfilled" } catch (reason) { return reason.name }
|
||||||
|
}
|
||||||
|
let fulfilled
|
||||||
|
let rejected
|
||||||
|
fulfilled = Promise.resolve().then(() => fulfilled)
|
||||||
|
rejected = Promise.reject().then(undefined, () => rejected)
|
||||||
|
return await Promise.all([observe(fulfilled), observe(rejected)])
|
||||||
|
`),
|
||||||
|
).toEqual(["TypeError", "TypeError"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("catch delegates rejection handling and preserves fulfillment", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js
|
||||||
|
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js
|
||||||
|
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T2.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
return [
|
||||||
|
await Promise.resolve(1).catch(() => 2),
|
||||||
|
await Promise.reject(3).catch((reason) => reason + 1),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([1, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("finally preserves or replaces the original settlement", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
|
||||||
|
// test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js
|
||||||
|
// test/built-ins/Promise/prototype/finally/rejection-reason-override-with-throw.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
return await Promise.all([
|
||||||
|
observe(Promise.resolve(1).finally(() => 2)),
|
||||||
|
observe(Promise.reject(3).finally(() => 4)),
|
||||||
|
observe(Promise.reject(5).finally(() => { throw 6 })),
|
||||||
|
])
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["fulfilled", 1],
|
||||||
|
["rejected", 3],
|
||||||
|
["rejected", 6],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("await always resumes in a later reaction and interleaves async functions", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/language/expressions/await/async-await-interleaved.js
|
||||||
|
// test/language/expressions/await/await-non-promise.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = []
|
||||||
|
const first = async () => { sequence.push("first:1"); await 0; sequence.push("first:2") }
|
||||||
|
const second = async () => { sequence.push("second:1"); await 0; sequence.push("second:2") }
|
||||||
|
await Promise.all([first(), second()])
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual(["first:1", "second:1", "first:2", "second:2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("an async function rejects when it resolves with its own promise", async () => {
|
||||||
|
// Adapted from the self-resolution requirement represented by:
|
||||||
|
// test/built-ins/Promise/resolve-self.js
|
||||||
|
// test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
let promise
|
||||||
|
const run = async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
promise = run()
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
return "fulfilled"
|
||||||
|
} catch (error) {
|
||||||
|
return error.name
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toBe("TypeError")
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise.resolve recursively assimilates callable thenables", async () => {
|
||||||
|
// Source: test/built-ins/Promise/resolve/resolve-thenable.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const value = { id: 1 }
|
||||||
|
const nested = { then: (resolve) => resolve(value) }
|
||||||
|
const thenable = { then: (resolve) => resolve(nested) }
|
||||||
|
return (await Promise.resolve(thenable)) === value
|
||||||
|
`),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("Promise combinators assimilate callable thenable inputs", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/all/reject-immed.js
|
||||||
|
// test/built-ins/Promise/all/reject-ignored-immed.js
|
||||||
|
// test/built-ins/Promise/allSettled/reject-ignored-immed.js
|
||||||
|
// test/built-ins/Promise/race/resolve-thenable.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const fulfills = { then: (resolve) => resolve(1) }
|
||||||
|
const rejects = { then: (_, reject) => reject(2) }
|
||||||
|
const resolvesFirst = { then: (resolve, reject) => { resolve(3); reject(4) } }
|
||||||
|
const observe = async (promise) => {
|
||||||
|
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
await observe(Promise.all([fulfills, rejects])),
|
||||||
|
await Promise.allSettled([fulfills, resolvesFirst]),
|
||||||
|
await observe(Promise.race([rejects])),
|
||||||
|
]
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
["rejected", 2],
|
||||||
|
[
|
||||||
|
{ status: "fulfilled", value: 1 },
|
||||||
|
{ status: "fulfilled", value: 3 },
|
||||||
|
],
|
||||||
|
["rejected", 2],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("await assimilates callable thenables", async () => {
|
||||||
|
// Source: test/language/expressions/await/await-awaits-thenables.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const thenable = { then: (resolve) => resolve(42) }
|
||||||
|
return await thenable
|
||||||
|
`),
|
||||||
|
).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.failing("await rejects when a callable thenable throws", async () => {
|
||||||
|
// Source: test/language/expressions/await/await-awaits-thenables-that-throw.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const error = { id: 1 }
|
||||||
|
const thenable = { then: () => { throw error } }
|
||||||
|
try {
|
||||||
|
await thenable
|
||||||
|
return false
|
||||||
|
} catch (caught) {
|
||||||
|
return caught === error
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -48,6 +48,13 @@ const failingTool = Tool.make({
|
||||||
run: () => Effect.fail(toolError("Lookup refused")),
|
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) =>
|
const completedTool = (trace: Trace) =>
|
||||||
Tool.make({
|
Tool.make({
|
||||||
description: "Return the number of completed sleepy calls",
|
description: "Return the number of completed sleepy calls",
|
||||||
|
|
@ -56,6 +63,25 @@ const completedTool = (trace: Trace) =>
|
||||||
run: () => Effect.succeed(trace.completed),
|
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 = (
|
const run = (
|
||||||
code: string,
|
code: string,
|
||||||
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
||||||
|
|
@ -63,7 +89,15 @@ const run = (
|
||||||
const trace = options.trace ?? makeTrace()
|
const trace = options.trace ?? makeTrace()
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
CodeMode.execute({
|
CodeMode.execute({
|
||||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } },
|
tools: {
|
||||||
|
host: {
|
||||||
|
sleepy: sleepyTool(trace),
|
||||||
|
fail: failingTool,
|
||||||
|
interrupt: interruptedTool,
|
||||||
|
completed: completedTool(trace),
|
||||||
|
stubborn: stubbornTool(trace),
|
||||||
|
},
|
||||||
|
},
|
||||||
code,
|
code,
|
||||||
...(options.limits ? { limits: options.limits } : {}),
|
...(options.limits ? { limits: options.limits } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -174,8 +208,7 @@ describe("first-class promise values", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
|
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
|
||||||
expect(
|
const result = await run(`
|
||||||
await value(`
|
|
||||||
const p = tools.host.fail({})
|
const p = tools.host.fail({})
|
||||||
try {
|
try {
|
||||||
await p
|
await p
|
||||||
|
|
@ -183,57 +216,195 @@ describe("first-class promise values", () => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.message
|
return e.message
|
||||||
}
|
}
|
||||||
`),
|
`)
|
||||||
).toBe("Lookup refused")
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value).toBe("Lookup refused")
|
||||||
|
expect(result.warnings).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a fire-and-forget call completes before the execution ends", async () => {
|
test("a fire-and-forget call is interrupted when the program returns", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await run(
|
||||||
`
|
`
|
||||||
tools.host.sleepy({ id: 1, ms: 30 })
|
tools.host.sleepy({ id: 1, ms: 30 })
|
||||||
return "done"
|
return "done"
|
||||||
`,
|
`,
|
||||||
{ trace },
|
{ trace },
|
||||||
)
|
)
|
||||||
expect(result).toBe("done")
|
expect(result.ok).toBe(true)
|
||||||
expect(trace.completed).toBe(1)
|
if (!result.ok) return
|
||||||
expect(trace.interrupted).toBe(0)
|
expect(result.value).toBe("done")
|
||||||
|
expect(result.warnings).toBeUndefined()
|
||||||
|
expect(trace.completed).toBe(0)
|
||||||
|
expect(trace.interrupted).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
|
test("a never-awaited failing call preserves the result and reports the rejection", async () => {
|
||||||
const diagnostic = await error(`
|
const result = await run(`
|
||||||
tools.host.fail({})
|
tools.host.fail({})
|
||||||
return "done"
|
return "done"
|
||||||
`)
|
`)
|
||||||
expect(diagnostic.kind).toBe("ToolFailure")
|
expect(result.ok).toBe(true)
|
||||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
if (!result.ok) return
|
||||||
expect(diagnostic.message).toContain("Lookup refused")
|
expect(result.value).toBe("done")
|
||||||
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
|
test("a never-awaited failing async function is reported with a successful result", async () => {
|
||||||
const diagnostic = await error(`
|
const result = await run(`
|
||||||
const fail = async () => { throw new Error("boom") }
|
const fail = async () => { throw new Error("boom") }
|
||||||
fail()
|
fail()
|
||||||
return "done"
|
return "done"
|
||||||
`)
|
`)
|
||||||
expect(diagnostic.kind).toBe("ExecutionFailure")
|
expect(result.ok).toBe(true)
|
||||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
if (!result.ok) return
|
||||||
expect(diagnostic.message).toContain("boom")
|
expect(result.value).toBe("done")
|
||||||
|
expect(result.warnings).toStrictEqual([
|
||||||
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drains promises started by an async function after an await", async () => {
|
test("output truncation bounds warning diagnostics with an in-band marker", async () => {
|
||||||
const diagnostic = await error(`
|
const result = await run(
|
||||||
const run = async () => {
|
`
|
||||||
await tools.host.sleepy({ id: 1 })
|
for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
|
||||||
tools.host.fail({})
|
return "done"
|
||||||
}
|
`,
|
||||||
run()
|
{ 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"
|
return "done"
|
||||||
`)
|
`)
|
||||||
expect(diagnostic.kind).toBe("ToolFailure")
|
expect(result.ok).toBe(true)
|
||||||
expect(diagnostic.message).toContain("Lookup refused")
|
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")
|
||||||
|
}
|
||||||
|
outer()
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -251,6 +422,22 @@ describe("promises at data boundaries", () => {
|
||||||
expect(diagnostic.message).toContain("un-awaited Promise")
|
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 () => {
|
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
|
||||||
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
|
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
|
||||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||||
|
|
@ -270,6 +457,59 @@ describe("promises at data boundaries", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Promise.all over arbitrary arrays", () => {
|
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 () => {
|
test("mixes promises and plain values, preserving order", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
|
|
@ -340,16 +580,18 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects with the first failure, catchable in-program", async () => {
|
test("rejects with the first failure, catchable in-program", async () => {
|
||||||
expect(
|
const result = await run(`
|
||||||
await value(`
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
||||||
return "no"
|
return "no"
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.message
|
return e.message
|
||||||
}
|
}
|
||||||
`),
|
`)
|
||||||
).toBe("Lookup refused")
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value).toBe("Lookup refused")
|
||||||
|
expect(result.warnings).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects before an earlier slow promise fulfills", async () => {
|
test("rejects before an earlier slow promise fulfills", async () => {
|
||||||
|
|
@ -370,10 +612,55 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||||
{ trace },
|
{ trace },
|
||||||
),
|
),
|
||||||
).toBe(0)
|
).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.completed).toBe(1)
|
||||||
expect(trace.interrupted).toBe(0)
|
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 () => {
|
test("a non-collection argument is a clear error", async () => {
|
||||||
const diagnostic = await error(`return await Promise.all(42)`)
|
const diagnostic = await error(`return await Promise.all(42)`)
|
||||||
expect(diagnostic.message).toContain("Promise.all expects an array")
|
expect(diagnostic.message).toContain("Promise.all expects an array")
|
||||||
|
|
@ -413,50 +700,64 @@ describe("Promise.allSettled", () => {
|
||||||
return settled.filter((s) => s.status === "rejected").length
|
return settled.filter((s) => s.status === "rejected").length
|
||||||
`)
|
`)
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) expect(result.value).toBe(2)
|
if (!result.ok) return
|
||||||
|
expect(result.value).toBe(2)
|
||||||
|
expect(result.warnings).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Promise.race", () => {
|
describe("Promise.race", () => {
|
||||||
test("first settlement wins and losers are interrupted", async () => {
|
test("first settlement wins and a direct loser is interrupted at completion", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await value(
|
||||||
`
|
`
|
||||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||||
return await Promise.race([fast, slow])
|
return await Promise.race([fast, slow])
|
||||||
`,
|
`,
|
||||||
{ trace },
|
{ trace },
|
||||||
)
|
)
|
||||||
expect(result).toBe(1)
|
expect(result).toBe(1)
|
||||||
expect(trace.interrupted).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.completed).toBe(1)
|
||||||
|
expect(trace.interrupted).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
|
test("a direct loser remains awaitable after the race settles", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||||
const winner = await Promise.race([fast, slow])
|
const winner = await Promise.race([fast, slow])
|
||||||
try {
|
return { winner, loser: await slow }
|
||||||
await slow
|
|
||||||
return "no"
|
|
||||||
} catch (e) {
|
|
||||||
return { winner, caught: e.message }
|
|
||||||
}
|
|
||||||
`),
|
`),
|
||||||
).toEqual({
|
).toEqual({ winner: 1, loser: 2 })
|
||||||
winner: 1,
|
})
|
||||||
caught: "This tool call was interrupted because another value settled a Promise.race first.",
|
|
||||||
})
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a rejection can win the race", async () => {
|
test("a rejection can win the race", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
try {
|
try {
|
||||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
|
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
|
||||||
return "no"
|
return "no"
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.message
|
return e.message
|
||||||
|
|
@ -468,11 +769,20 @@ describe("Promise.race", () => {
|
||||||
test("a plain value wins over pending promises", async () => {
|
test("a plain value wins over pending promises", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
expect(
|
expect(
|
||||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
|
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
|
||||||
).toBe("immediate")
|
).toBe("immediate")
|
||||||
|
expect(trace.completed).toBe(0)
|
||||||
expect(trace.interrupted).toBe(1)
|
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 () => {
|
test("an empty race is a clear error instead of hanging", async () => {
|
||||||
const diagnostic = await error(`return await Promise.race([])`)
|
const diagnostic = await error(`return await Promise.race([])`)
|
||||||
expect(diagnostic.message).toContain("never settle")
|
expect(diagnostic.message).toContain("never settle")
|
||||||
|
|
@ -484,6 +794,9 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||||
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
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(Promise.resolve("nested"))`)).toBe("nested")
|
||||||
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
|
expect(await value(`return await Promise.resolve(tools.host.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 () => {
|
test("reject produces a promise whose await throws the reason", async () => {
|
||||||
|
|
@ -498,6 +811,34 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||||
`),
|
`),
|
||||||
).toBe("nope")
|
).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", () => {
|
describe("timeout interruption of forked calls", () => {
|
||||||
|
|
@ -531,6 +872,67 @@ describe("timeout interruption of forked calls", () => {
|
||||||
expect(result.error.kind).toBe("TimeoutExceeded")
|
expect(result.error.kind).toBe("TimeoutExceeded")
|
||||||
expect(trace.interrupted).toBe(2)
|
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("unsupported promise surface", () => {
|
describe("unsupported promise surface", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# 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,8 +1,10 @@
|
||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "01451b27-1e51-4657-b2d0-4b457dffa3ec",
|
"id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed",
|
||||||
"prevIds": ["8c1748cc-f978-4df4-879d-fe7fda5c4c34"],
|
"prevIds": [
|
||||||
|
"666138ef-82cb-4a9a-a765-e6669a436ff3"
|
||||||
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
|
|
@ -49,13 +51,17 @@
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "instruction_checkpoint",
|
"name": "instruction_blob",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "instruction_entry",
|
"name": "instruction_entry",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "instruction_state",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "message",
|
"name": "message",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
|
|
@ -65,11 +71,11 @@
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "session_input",
|
"name": "session_message",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "session_message",
|
"name": "session_pending",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -786,39 +792,19 @@
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
"name": "session_id",
|
"name": "hash",
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "instruction_checkpoint"
|
"table": "instruction_blob"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
"name": "baseline",
|
"name": "value",
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "instruction_checkpoint"
|
"table": "instruction_blob"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
"type": "text",
|
||||||
|
|
@ -842,7 +828,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
|
|
@ -850,6 +836,16 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "instruction_entry"
|
"table": "instruction_entry"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"name": "removed",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "instruction_entry"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|
@ -870,6 +866,56 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "instruction_entry"
|
"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",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -980,86 +1026,6 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "session_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "type",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "data",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "delivery",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "admitted_seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "promoted_seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "time_created",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -1130,6 +1096,76 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "session_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "type",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "data",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "delivery",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "admitted_seq",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_created",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -1190,6 +1226,16 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session"
|
"table": "session"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "fork_seq",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|
@ -1511,9 +1557,13 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1522,9 +1572,13 @@
|
||||||
"table": "workspace"
|
"table": "workspace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["active_account_id"],
|
"columns": [
|
||||||
|
"active_account_id"
|
||||||
|
],
|
||||||
"tableTo": "account",
|
"tableTo": "account",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "SET NULL",
|
"onDelete": "SET NULL",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1533,9 +1587,13 @@
|
||||||
"table": "account_state"
|
"table": "account_state"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["aggregate_id"],
|
"columns": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"tableTo": "event_sequence",
|
"tableTo": "event_sequence",
|
||||||
"columnsTo": ["aggregate_id"],
|
"columnsTo": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1544,9 +1602,13 @@
|
||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1555,9 +1617,13 @@
|
||||||
"table": "permission"
|
"table": "permission"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1566,20 +1632,13 @@
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
"onUpdate": "NO ACTION",
|
"id"
|
||||||
"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",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1588,9 +1647,28 @@
|
||||||
"table": "instruction_entry"
|
"table": "instruction_entry"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"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"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1599,9 +1677,13 @@
|
||||||
"table": "message"
|
"table": "message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["message_id"],
|
"columns": [
|
||||||
|
"message_id"
|
||||||
|
],
|
||||||
"tableTo": "message",
|
"tableTo": "message",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1610,20 +1692,13 @@
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
"onUpdate": "NO ACTION",
|
"id"
|
||||||
"onDelete": "CASCADE",
|
],
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "fk_session_input_session_id_session_id_fk",
|
|
||||||
"entityType": "fks",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["session_id"],
|
|
||||||
"tableTo": "session",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1632,9 +1707,28 @@
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
|
"tableTo": "session",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "fk_session_input_session_id_session_id_fk",
|
||||||
|
"entityType": "fks",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1643,9 +1737,13 @@
|
||||||
"table": "session"
|
"table": "session"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1654,133 +1752,183 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["email", "url"],
|
"columns": [
|
||||||
|
"email",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "control_account_pk",
|
"name": "control_account_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "control_account"
|
"table": "control_account"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id", "directory"],
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"directory"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_directory_pk",
|
"name": "project_directory_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id", "key"],
|
"columns": [
|
||||||
|
"session_id",
|
||||||
|
"key"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "instruction_entry_pk",
|
"name": "instruction_entry_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "instruction_entry"
|
"table": "instruction_entry"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "workspace_pk",
|
"name": "workspace_pk",
|
||||||
"table": "workspace",
|
"table": "workspace",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["name"],
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "data_migration_pk",
|
"name": "data_migration_pk",
|
||||||
"table": "data_migration",
|
"table": "data_migration",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_state_pk",
|
"name": "account_state_pk",
|
||||||
"table": "account_state",
|
"table": "account_state",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_pk",
|
"name": "account_pk",
|
||||||
"table": "account",
|
"table": "account",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "credential_pk",
|
"name": "credential_pk",
|
||||||
"table": "credential",
|
"table": "credential",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["aggregate_id"],
|
"columns": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_sequence_pk",
|
"name": "event_sequence_pk",
|
||||||
"table": "event_sequence",
|
"table": "event_sequence",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_pk",
|
"name": "event_pk",
|
||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "permission_pk",
|
"name": "permission_pk",
|
||||||
"table": "permission",
|
"table": "permission",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_pk",
|
"name": "project_pk",
|
||||||
"table": "project",
|
"table": "project",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"hash"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "instruction_checkpoint_pk",
|
"name": "instruction_blob_pk",
|
||||||
"table": "instruction_checkpoint",
|
"table": "instruction_blob",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "instruction_state_pk",
|
||||||
|
"table": "instruction_state",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "message_pk",
|
"name": "message_pk",
|
||||||
"table": "message",
|
"table": "message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "part_pk",
|
"name": "part_pk",
|
||||||
"table": "part",
|
"table": "part",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
"nameExplicit": false,
|
"id"
|
||||||
"name": "session_input_pk",
|
],
|
||||||
"table": "session_input",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_message_pk",
|
"name": "session_message_pk",
|
||||||
"table": "session_message",
|
"table": "session_message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "session_input_pk",
|
||||||
|
"table": "session_pending",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_pk",
|
"name": "session_pk",
|
||||||
"table": "session",
|
"table": "session",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_share_pk",
|
"name": "session_share_pk",
|
||||||
"table": "session_share",
|
"table": "session_share",
|
||||||
|
|
@ -1902,82 +2050,6 @@
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "promoted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "delivery",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "admitted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_pending_delivery_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_pending_compaction_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "admitted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_admitted_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "promoted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_promoted_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -2054,6 +2126,60 @@
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "delivery",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admitted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_delivery_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"session_pending\".\"type\" = 'compaction'",
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_compaction_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admitted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_admitted_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,11 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||||
path: AbsolutePath,
|
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")({
|
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||||
type: Schema.Literal("agents"),
|
type: Schema.Literal("agents"),
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
|
|
@ -129,7 +134,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
|
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
||||||
|
|
||||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
return entries
|
return entries
|
||||||
|
|
@ -138,7 +143,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Returns location config documents and supplemental directories from lowest to highest priority. */
|
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||||
readonly entries: () => Effect.Effect<Entry[]>
|
readonly entries: () => Effect.Effect<Entry[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,31 +232,36 @@ const layer = Layer.effect(
|
||||||
const directPaths = discovered
|
const directPaths = discovered
|
||||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
const direct = yield* Effect.forEach(directPaths, (filepath) =>
|
||||||
|
loadFile(filepath).pipe(
|
||||||
|
Effect.map((config) => [
|
||||||
|
...(config ? [config] : []),
|
||||||
|
new File({ type: "file", path: AbsolutePath.make(filepath) }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
Effect.map((entries) => entries.flat()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return {
|
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||||
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()
|
const initial = yield* discover()
|
||||||
let configs = initial.entries
|
let configs = initial
|
||||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||||
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
||||||
const targets = (snapshot: typeof initial) => [
|
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||||
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
|
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||||
...snapshot.files
|
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
|
||||||
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
|
const targets = [
|
||||||
.map((path) => ({ path, type: "file" as const })),
|
...directories.map((path) => ({ path, type: "directory" as const })),
|
||||||
]
|
...files
|
||||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
|
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
|
||||||
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
|
.map((path) => ({ path, type: "file" as const })),
|
||||||
|
]
|
||||||
|
const next = new Map(targets.map((target) => [JSON.stringify(target), target]))
|
||||||
for (const [key, stop] of subscriptions) {
|
for (const [key, stop] of subscriptions) {
|
||||||
if (next.has(key)) continue
|
if (next.has(key)) continue
|
||||||
yield* stop
|
yield* stop
|
||||||
|
|
@ -272,7 +282,7 @@ const layer = Layer.effect(
|
||||||
Stream.runForEach((update) =>
|
Stream.runForEach((update) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* discover()
|
const next = yield* discover()
|
||||||
configs = next.entries
|
configs = next
|
||||||
yield* reconcile(next)
|
yield* reconcile(next)
|
||||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Location } from "../location"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { SessionEvent } from "../session/event"
|
import { SessionEvent } from "../session/event"
|
||||||
|
import { SessionExecution } from "../session/execution"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { SessionStore } from "../session/store"
|
import { SessionStore } from "../session/store"
|
||||||
import { AbsolutePath, RelativePath } from "../schema"
|
import { AbsolutePath, RelativePath } from "../schema"
|
||||||
|
|
@ -73,6 +74,7 @@ const layer = Layer.effect(
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const project = yield* ProjectV2.Service
|
const project = yield* ProjectV2.Service
|
||||||
const sessions = yield* SessionStore.Service
|
const sessions = yield* SessionStore.Service
|
||||||
|
const execution = yield* SessionExecution.Service
|
||||||
|
|
||||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||||
const current = yield* sessions.get(input.sessionID)
|
const current = yield* sessions.get(input.sessionID)
|
||||||
|
|
@ -86,6 +88,12 @@ const layer = Layer.effect(
|
||||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
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 moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||||
if (moveChanges && !sourceRepository)
|
if (moveChanges && !sourceRepository)
|
||||||
|
|
@ -143,5 +151,5 @@ const layer = Layer.effect(
|
||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
|
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,10 @@ export function path() {
|
||||||
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
// Resolve the database path lazily so tests and embedders that set
|
||||||
|
// Flag.OPENCODE_DB after module evaluation still control the storage target.
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer: Layer.suspend(() => layerFromPath(path())),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
|
|
||||||
2
packages/core/src/database/migration.gen.ts
generated
2
packages/core/src/database/migration.gen.ts
generated
|
|
@ -52,5 +52,7 @@ export const migrations = (
|
||||||
import("./migration/20260709013000_generic_session_input"),
|
import("./migration/20260709013000_generic_session_input"),
|
||||||
import("./migration/20260709025533_drop-todo"),
|
import("./migration/20260709025533_drop-todo"),
|
||||||
import("./migration/20260709163752_time_suspended"),
|
import("./migration/20260709163752_time_suspended"),
|
||||||
|
import("./migration/20260709190621_session_pending_table"),
|
||||||
|
import("./migration/20260710025429_instruction_sync"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260709190621_session_pending_table",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
// Beta reset: session_input becomes the pending-only session_pending
|
||||||
|
// table. Dropping the old table discards consumed ledger rows and any
|
||||||
|
// in-flight pending work along with every historical index variant.
|
||||||
|
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`session_pending\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`session_id\` text NOT NULL,
|
||||||
|
\`type\` text NOT NULL,
|
||||||
|
\`data\` text NOT NULL,
|
||||||
|
\`delivery\` text,
|
||||||
|
\`admitted_seq\` integer NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
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
|
||||||
|
|
@ -126,25 +126,33 @@ export default {
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`instruction_checkpoint\` (
|
CREATE TABLE \`instruction_blob\` (
|
||||||
\`session_id\` text PRIMARY KEY,
|
\`hash\` text PRIMARY KEY,
|
||||||
\`baseline\` text NOT NULL,
|
\`value\` text
|
||||||
\`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(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`instruction_entry\` (
|
CREATE TABLE \`instruction_entry\` (
|
||||||
\`session_id\` text NOT NULL,
|
\`session_id\` text NOT NULL,
|
||||||
\`key\` text NOT NULL,
|
\`key\` text NOT NULL,
|
||||||
\`value\` text NOT NULL,
|
\`value\` text,
|
||||||
|
\`removed\` integer DEFAULT false NOT NULL,
|
||||||
\`time_created\` integer NOT NULL,
|
\`time_created\` integer NOT NULL,
|
||||||
\`time_updated\` integer NOT NULL,
|
\`time_updated\` integer NOT NULL,
|
||||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
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
|
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(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`message\` (
|
CREATE TABLE \`message\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|
@ -166,19 +174,6 @@ export default {
|
||||||
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
yield* tx.run(`
|
|
||||||
CREATE TABLE \`session_input\` (
|
|
||||||
\`id\` text PRIMARY KEY,
|
|
||||||
\`session_id\` text NOT NULL,
|
|
||||||
\`type\` text NOT NULL,
|
|
||||||
\`data\` text NOT NULL,
|
|
||||||
\`delivery\` text,
|
|
||||||
\`admitted_seq\` integer NOT NULL,
|
|
||||||
\`promoted_seq\` integer,
|
|
||||||
\`time_created\` integer NOT NULL,
|
|
||||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`session_message\` (
|
CREATE TABLE \`session_message\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|
@ -191,6 +186,18 @@ export default {
|
||||||
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`session_pending\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`session_id\` text NOT NULL,
|
||||||
|
\`type\` text NOT NULL,
|
||||||
|
\`data\` text NOT NULL,
|
||||||
|
\`delivery\` text,
|
||||||
|
\`admitted_seq\` integer NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`)
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`session\` (
|
CREATE TABLE \`session\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|
@ -199,6 +206,7 @@ export default {
|
||||||
\`parent_id\` text,
|
\`parent_id\` text,
|
||||||
\`fork_session_id\` text,
|
\`fork_session_id\` text,
|
||||||
\`fork_message_id\` text,
|
\`fork_message_id\` text,
|
||||||
|
\`fork_seq\` integer,
|
||||||
\`slug\` text NOT NULL,
|
\`slug\` text NOT NULL,
|
||||||
\`directory\` text NOT NULL,
|
\`directory\` text NOT NULL,
|
||||||
\`path\` text,
|
\`path\` text,
|
||||||
|
|
@ -249,18 +257,6 @@ export default {
|
||||||
)
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||||
yield* tx.run(
|
|
||||||
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
yield* tx.run(
|
||||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||||
)
|
)
|
||||||
|
|
@ -271,6 +267,15 @@ export default {
|
||||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||||
)
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,8 @@ export interface Interface {
|
||||||
) => Effect.Effect<Payload<D>>
|
) => Effect.Effect<Payload<D>>
|
||||||
readonly subscribe: Subscribe
|
readonly subscribe: Subscribe
|
||||||
/**
|
/**
|
||||||
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
|
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
|
||||||
|
* inherited prefix before their first child-authored event. `follow: false`
|
||||||
* completes at the end of the log; `follow: true` replays then transitions
|
* completes at the end of the log; `follow: true` replays then transitions
|
||||||
* to live. Both modes emit one `Synced` marker at the captured replay
|
* to live. Both modes emit one `Synced` marker at the captured replay
|
||||||
* watermark.
|
* watermark.
|
||||||
|
|
@ -203,7 +204,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||||
}
|
}
|
||||||
const projectors = new Map<string, Subscriber[]>()
|
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 listeners = new Array<Subscriber>()
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const logReadPageSize = options?.logReadPageSize ?? 512
|
const logReadPageSize = options?.logReadPageSize ?? 512
|
||||||
|
|
@ -260,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const list = projectors.get(event.type) ?? []
|
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||||
return yield* Effect.uninterruptible(
|
return yield* Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const committed = yield* db
|
const committed = yield* db
|
||||||
|
|
@ -515,18 +515,6 @@ 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) {
|
for (const event of events) {
|
||||||
yield* replay(event, options)
|
yield* replay(event, options)
|
||||||
}
|
}
|
||||||
|
|
@ -727,9 +715,10 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
|
|
||||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const list = projectors.get(definition.type) ?? []
|
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
|
||||||
|
const list = projectors.get(key) ?? []
|
||||||
list.push((event) => projector(event as Payload<D>))
|
list.push((event) => projector(event as Payload<D>))
|
||||||
projectors.set(definition.type, list)
|
projectors.set(key, list)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export type Info = typeof Info.Type
|
||||||
export const Field = Form.Field
|
export const Field = Form.Field
|
||||||
export type Field = Form.Field
|
export type Field = Form.Field
|
||||||
|
|
||||||
|
export const Fields = Form.Fields
|
||||||
|
export type Fields = Form.Fields
|
||||||
|
|
||||||
export const When = Form.When
|
export const When = Form.When
|
||||||
export type When = Form.When
|
export type When = Form.When
|
||||||
|
|
||||||
|
|
@ -64,9 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type CreateInput =
|
export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
|
||||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
|
||||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
|
||||||
|
|
||||||
export interface ReplyInput {
|
export interface ReplyInput {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
|
|
@ -74,7 +75,7 @@ export interface ReplyInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListInput {
|
export interface ListInput {
|
||||||
readonly sessionID?: Form.FormInfo["sessionID"]
|
readonly sessionID?: Form.Info["sessionID"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
@ -125,20 +126,15 @@ export const layer = Layer.effect(
|
||||||
const id = input.id ?? ID.create()
|
const id = input.id ?? ID.create()
|
||||||
const existing = yield* Cache.getSuccess(forms, id)
|
const existing = yield* Cache.getSuccess(forms, id)
|
||||||
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
||||||
if (input.mode === "form") {
|
const invalid = validateFields(input.fields)
|
||||||
const invalid = validateFields(input.fields)
|
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
||||||
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
const form: Info = {
|
||||||
}
|
|
||||||
const base = {
|
|
||||||
id,
|
id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(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 = {
|
const entry: Entry = {
|
||||||
form,
|
form,
|
||||||
state: { status: "pending" },
|
state: { status: "pending" },
|
||||||
|
|
@ -228,16 +224,16 @@ export const locationLayer = layer
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||||
|
|
||||||
function validateAnswer(form: Info, answer: Answer) {
|
function validateAnswer(form: Info, answer: Answer) {
|
||||||
if (form.mode === "url") {
|
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
|
||||||
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)) {
|
for (const key of Object.keys(answer)) {
|
||||||
if (!fields.has(key)) return `Unknown form field: ${key}`
|
if (!fields.has(key)) return `Unknown form field: ${key}`
|
||||||
}
|
}
|
||||||
for (const field of form.fields) {
|
for (const field of form.fields) {
|
||||||
const value = answer[field.key]
|
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)
|
const active = isActive(field, answer)
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
if (field.required && active) return `Missing required form field: ${field.key}`
|
if (field.required && active) return `Missing required form field: ${field.key}`
|
||||||
|
|
@ -249,7 +245,9 @@ function validateAnswer(form: Info, answer: Answer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(field: Form.Field, answer: Answer) {
|
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||||
|
|
||||||
|
function isActive(field: InputField, answer: Answer) {
|
||||||
if (!field.when) return true
|
if (!field.when) return true
|
||||||
return field.when.every((when) => matches(when, answer[when.key]))
|
return field.when.every((when) => matches(when, answer[when.key]))
|
||||||
}
|
}
|
||||||
|
|
@ -267,9 +265,13 @@ function matches(when: Form.When, value: Form.Value | undefined) {
|
||||||
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
||||||
// silently never matching.
|
// silently never matching.
|
||||||
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||||
const earlier = new Map<string, 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>()
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
|
if (keys.has(field.key)) return `Duplicate form field key: ${field.key}`
|
||||||
|
keys.add(field.key)
|
||||||
|
if (field.type === "external") continue
|
||||||
for (const when of field.when ?? []) {
|
for (const when of field.when ?? []) {
|
||||||
const target = earlier.get(when.key)
|
const target = earlier.get(when.key)
|
||||||
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
|
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
|
||||||
|
|
@ -280,7 +282,7 @@ function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateWhen(when: Form.When, target: Form.Field) {
|
function validateWhen(when: Form.When, target: InputField) {
|
||||||
if (target.type === "boolean") {
|
if (target.type === "boolean") {
|
||||||
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
|
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
|
||||||
return
|
return
|
||||||
|
|
@ -297,7 +299,7 @@ function validateWhen(when: Form.When, target: Form.Field) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateField(field: Form.Field, value: Form.Value): string | undefined {
|
function validateField(field: InputField, value: Form.Value): string | undefined {
|
||||||
if (field.type === "string") {
|
if (field.type === "string") {
|
||||||
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
|
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}`
|
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,17 @@ const layer = Layer.effect(
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable) =>
|
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||||
Instructions.make({
|
Instructions.make<ReadonlyArray<File>>({
|
||||||
key,
|
key,
|
||||||
codec: Schema.toCodecJson(Files),
|
codec: Schema.toCodecJson(Files),
|
||||||
load: Effect.succeed(value),
|
read: Effect.succeed(value),
|
||||||
baseline: render,
|
render: {
|
||||||
update: (_previous, current) =>
|
initial: render,
|
||||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
changed: (_previous, current) =>
|
||||||
removed: () => "Previously loaded instructions no longer apply.",
|
`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* () {
|
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||||
|
|
@ -82,11 +84,7 @@ const layer = Layer.effect(
|
||||||
load: () =>
|
load: () =>
|
||||||
observe().pipe(
|
observe().pipe(
|
||||||
Effect.map((files) =>
|
Effect.map((files) =>
|
||||||
files === Instructions.unavailable
|
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||||
? source(files)
|
|
||||||
: files.length === 0
|
|
||||||
? Instructions.empty
|
|
||||||
: source(files),
|
|
||||||
),
|
),
|
||||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||||
|
|
|
||||||
|
|
@ -15,29 +15,34 @@ const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const location = yield* Location.Service
|
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([
|
const instructions = Instructions.combine([
|
||||||
Instructions.make({
|
Instructions.make({
|
||||||
key: Instructions.Key.make("core/environment"),
|
key: Instructions.Key.make("core/environment"),
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed(environment),
|
read: Effect.sync(() =>
|
||||||
baseline: (environment) =>
|
[
|
||||||
["Here is some useful information about the environment you are running in:", environment].join("\n"),
|
"<env>",
|
||||||
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
|
` 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"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Instructions.make({
|
Instructions.make({
|
||||||
key: Instructions.Key.make("core/date"),
|
key: Instructions.Key.make("core/date"),
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||||
baseline: (date) => `Today's date: ${date}`,
|
render: {
|
||||||
update: (_previous, date) => `Today's date is now: ${date}`,
|
initial: (date) => `Today's date: ${date}`,
|
||||||
|
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,70 @@
|
||||||
export * as Instructions from "./index"
|
export * as Instructions from "./index"
|
||||||
|
|
||||||
import { Effect, Option, Schema } from "effect"
|
import { createHash } from "crypto"
|
||||||
|
import { Instruction } from "@opencode-ai/schema/instruction"
|
||||||
|
import { Data, Effect, Option, Schema } from "effect"
|
||||||
|
|
||||||
/**
|
export const Key = Instruction.Key
|
||||||
* Models privileged instructions as independently refreshable typed sources.
|
export type Key = Instruction.Key
|
||||||
*
|
export const Hash = Instruction.Hash
|
||||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
export type Hash = Instruction.Hash
|
||||||
* closes over `A`, producing opaque `Instructions` that compose uniformly with
|
export const Values = Instruction.Values
|
||||||
* instructions built from other value types.
|
export type Values = Instruction.Values
|
||||||
*
|
export const Delta = Instruction.Delta
|
||||||
* The durable `Applied` record tracks what the model was last told, per source:
|
export type Delta = Instruction.Delta
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Stable namespaced identity for one independently refreshable instruction source. */
|
type NonValue = Data.TaggedEnum<{ Unavailable: {}; Removed: {} }>
|
||||||
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
|
const NonValue = Data.taggedEnum<NonValue>()
|
||||||
Schema.brand("Instructions.Key"),
|
|
||||||
)
|
|
||||||
export type Key = typeof Key.Type
|
|
||||||
|
|
||||||
/** Indicates that a source could not be observed without treating it as removed. */
|
/** The read failed temporarily; the stored value stands. */
|
||||||
export const unavailable = Symbol.for("@opencode/Instructions.Unavailable")
|
export const unavailable = NonValue.Unavailable()
|
||||||
export type Unavailable = typeof unavailable
|
export type Unavailable = typeof unavailable
|
||||||
|
|
||||||
/** Defines one typed source before its value type is hidden by `make`. */
|
/** An observed absence: the source exists but its value is gone. */
|
||||||
export interface Source<A> {
|
export const removed = NonValue.Removed()
|
||||||
|
export type Removed = typeof removed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
export interface Source {
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
readonly codec: Schema.Codec<A, Schema.Json, never, never>
|
readonly read: Effect.Effect<Schema.Json | Unavailable | Removed>
|
||||||
readonly load: Effect.Effect<A | Unavailable>
|
readonly initial: (value: Schema.Json) => string | undefined
|
||||||
readonly baseline: (current: A) => string
|
readonly changed: (previous: Schema.Json, current: Schema.Json) => string | undefined
|
||||||
readonly update: (previous: A, current: A) => string
|
readonly removed: (previous: Schema.Json) => string | undefined
|
||||||
readonly removed?: (previous: A) => string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstructionsTypeId: unique symbol = Symbol.for("@opencode/Instructions")
|
export declare namespace Source {
|
||||||
|
/** The typed definition supplied when constructing a source. */
|
||||||
/** Opaque carrier for composable instruction sources. */
|
export interface Definition<A> {
|
||||||
export interface Instructions {
|
readonly key: Key
|
||||||
readonly [InstructionsTypeId]: ReadonlyArray<PackedSource>
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The value last applied to the model for one admitted source. */
|
/** Ordered sources; identical values render identical bytes. */
|
||||||
export const AppliedSource = Schema.Struct({
|
export type Instructions = ReadonlyArray<Source>
|
||||||
value: Schema.Json,
|
|
||||||
removed: Schema.optional(Schema.NonEmptyString),
|
|
||||||
})
|
|
||||||
export type AppliedSource = typeof AppliedSource.Type
|
|
||||||
|
|
||||||
/** Durable record of what the model currently believes, per source. */
|
export type ReadResult = ReadonlyArray<{
|
||||||
export const Applied = Schema.Record(Key, AppliedSource)
|
readonly key: Key
|
||||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
readonly value: Schema.Json | Unavailable | Removed
|
||||||
|
}>
|
||||||
|
|
||||||
/** A rendered baseline together with the applied values it was rendered from. */
|
export interface Admission {
|
||||||
export interface Baseline {
|
readonly delta: Delta
|
||||||
readonly text: string
|
readonly blobs: Readonly<Record<string, Schema.Json>>
|
||||||
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>()(
|
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||||
"Instructions.InitializationBlocked",
|
"Instructions.InitializationBlocked",
|
||||||
{ keys: Schema.Array(Key) },
|
{ keys: Schema.Array(Key) },
|
||||||
|
|
@ -92,71 +82,140 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackedSource {
|
export const empty: Instructions = []
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Observed {
|
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
|
||||||
readonly applied: AppliedSource
|
export function make<A>(source: Source.Definition<A>): Instructions {
|
||||||
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 decode = Schema.decodeUnknownOption(source.codec)
|
||||||
const encode = Schema.encodeSync(source.codec)
|
const encode = Schema.encodeSync(source.codec)
|
||||||
const equivalent = Schema.toEquivalence(source.codec)
|
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
|
||||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
|
||||||
return instructions([
|
return [
|
||||||
{
|
{
|
||||||
key: source.key,
|
key: source.key,
|
||||||
recall: (stored) =>
|
read: source.read.pipe(
|
||||||
Option.match(decode(stored.value), {
|
|
||||||
onNone: () => undefined,
|
|
||||||
onSome: baseline,
|
|
||||||
}),
|
|
||||||
load: source.load.pipe(
|
|
||||||
Effect.map((value) => {
|
Effect.map((value) => {
|
||||||
if (isUnavailable(value)) return value
|
if (isUnavailable(value)) return unavailable
|
||||||
return {
|
if (isRemoved(value)) return removed
|
||||||
applied: {
|
return encode(value)
|
||||||
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>(
|
export function diffByKey<A>(
|
||||||
previous: ReadonlyArray<A>,
|
previous: ReadonlyArray<A>,
|
||||||
current: ReadonlyArray<A>,
|
current: ReadonlyArray<A>,
|
||||||
|
|
@ -179,129 +238,31 @@ 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>) {
|
function render(parts: ReadonlyArray<string>) {
|
||||||
return parts.join("\n\n")
|
return parts.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(applied: Applied, key: Key) {
|
// Reference-equality guards: `A` in a typed source may itself be JSON shaped
|
||||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
// like these singletons, so identity, never structure, discriminates.
|
||||||
}
|
|
||||||
|
|
||||||
function isUnavailable(value: unknown): value is Unavailable {
|
function isUnavailable(value: unknown): value is Unavailable {
|
||||||
return value === 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) {
|
function requireText(key: Key, kind: string, text: string) {
|
||||||
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
|
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
|
||||||
return text
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,19 @@ export const layer = Layer.effect(
|
||||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||||
const agent = selection.info
|
const agent = selection.info
|
||||||
if (!agent) return Instructions.empty
|
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")
|
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||||
return Instructions.empty
|
return source(Instructions.removed)
|
||||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
|
|
@ -88,15 +99,8 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||||
if (visible.length === 0) return Instructions.empty
|
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||||
return Instructions.make({
|
return source(visible.length === 0 ? Instructions.removed : visible)
|
||||||
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.",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ type ServerEntry = {
|
||||||
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
// 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.
|
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
|
||||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||||
|
const URL_ELICITATION_FIELD_KEY = "elicitation"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||||
|
|
@ -311,8 +312,7 @@ export const layer = Layer.effect(
|
||||||
elicitationID: input.params.elicitationId,
|
elicitationID: input.params.elicitationId,
|
||||||
message: input.params.message,
|
message: input.params.message,
|
||||||
},
|
},
|
||||||
mode: "url",
|
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
|
||||||
url: input.params.url,
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.raceFirst(waitForAbort(input.signal)),
|
Effect.raceFirst(waitForAbort(input.signal)),
|
||||||
|
|
@ -325,15 +325,16 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const params = input.params
|
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
|
return yield* forms
|
||||||
.ask({
|
.ask({
|
||||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||||
title: `${input.server} is requesting input`,
|
title: `${input.server} is requesting input`,
|
||||||
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
||||||
mode: "form",
|
fields: [field, ...fields],
|
||||||
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
|
||||||
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.raceFirst(waitForAbort(input.signal)),
|
Effect.raceFirst(waitForAbort(input.signal)),
|
||||||
|
|
@ -355,7 +356,7 @@ export const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
||||||
if (!formID) return
|
if (!formID) return
|
||||||
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
|
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
|
||||||
}),
|
}),
|
||||||
} satisfies MCPClient.ElicitationHandler
|
} satisfies MCPClient.ElicitationHandler
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { ToolHooks } from "./tool/hooks"
|
||||||
import { PluginHooks } from "./plugin/hooks"
|
import { PluginHooks } from "./plugin/hooks"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
|
readonly activate: (plugins: readonly Plugin[]) => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,72 +32,72 @@ const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
const active = new Map<typeof ID.Type, Scope.Closeable>()
|
const active = new Map<typeof ID.Type, { readonly plugin: Plugin; readonly scope: Scope.Closeable }>()
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = []
|
|
||||||
let host: Parameters<Plugin["effect"]>[0]
|
let host: Parameters<Plugin["effect"]>[0]
|
||||||
|
|
||||||
const activate = Effect.fn("Plugin.activate")(function* (
|
const load = Effect.fnUntraced(function* (plugin: Plugin) {
|
||||||
plugins: readonly { readonly plugin: Plugin; readonly version?: string }[],
|
const child = yield* Scope.fork(scope)
|
||||||
) {
|
const inherit = yield* State.inherit()
|
||||||
const definitions = plugins.map((entry) => ({
|
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||||
...entry.plugin,
|
inherit,
|
||||||
id: ID.make(entry.plugin.id),
|
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
||||||
...(entry.version === undefined ? {} : { version: entry.version }),
|
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||||
}))
|
Effect.andThen(events.publish(Event.Added, { id: ID.make(plugin.id) })),
|
||||||
|
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||||
|
Effect.exit,
|
||||||
|
)
|
||||||
|
if (Exit.isSuccess(loaded)) return child
|
||||||
|
yield* Effect.logWarning("failed to load plugin", {
|
||||||
|
"plugin.id": plugin.id,
|
||||||
|
cause: loaded.cause,
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
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>()
|
const ids = new Set<typeof ID.Type>()
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`))
|
if (ids.has(definition.id)) yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`))
|
||||||
ids.add(definition.id)
|
ids.add(definition.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* lock.withPermit(
|
yield* lock.withPermit(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (
|
|
||||||
generation !== undefined &&
|
|
||||||
generation.length === definitions.length &&
|
|
||||||
generation.every(
|
|
||||||
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
|
|
||||||
) &&
|
|
||||||
definitions.every((definition) => active.has(definition.id))
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
generation = undefined
|
|
||||||
yield* State.batch(
|
yield* State.batch(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const scopes = Array.from(active.values()).toReversed()
|
|
||||||
active.clear()
|
|
||||||
const inherit = yield* State.inherit()
|
|
||||||
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), {
|
|
||||||
discard: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
const child = yield* Scope.fork(scope)
|
const previous = active.get(definition.id)
|
||||||
const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe(
|
active.delete(definition.id)
|
||||||
inherit,
|
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||||
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
|
||||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }),
|
const loaded = yield* load(definition)
|
||||||
Effect.andThen(events.publish(Event.Added, { id: definition.id })),
|
if (loaded) {
|
||||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
active.set(definition.id, { plugin: definition, scope: loaded })
|
||||||
Effect.exit,
|
|
||||||
)
|
|
||||||
if (Exit.isFailure(loaded)) {
|
|
||||||
yield* Effect.logWarning("failed to load plugin", {
|
|
||||||
"plugin.id": definition.id,
|
|
||||||
cause: loaded.cause,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
active.set(definition.id, child)
|
|
||||||
|
if (!previous) continue
|
||||||
|
const restored = yield* load(previous.plugin)
|
||||||
|
if (restored) {
|
||||||
|
active.set(definition.id, { plugin: previous.plugin, scope: restored })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||||
|
"plugin.id": definition.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removed = Array.from(active.entries())
|
||||||
|
.filter(([id]) => !ids.has(id))
|
||||||
|
.toReversed()
|
||||||
|
removed.forEach(([id]) => active.delete(id))
|
||||||
|
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||||
|
discard: true,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
generation = definitions.map((definition) => ({
|
|
||||||
id: definition.id,
|
|
||||||
...(definition.version === undefined ? {} : { version: definition.version }),
|
|
||||||
}))
|
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -106,7 +106,6 @@ const layer = Layer.effect(
|
||||||
yield* Effect.addFinalizer((exit) =>
|
yield* Effect.addFinalizer((exit) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
active.clear()
|
active.clear()
|
||||||
generation = []
|
|
||||||
yield* State.batch(Scope.close(scope, exit))
|
yield* State.batch(Scope.close(scope, exit))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export * as PluginHooks from "./hooks"
|
export * as PluginHooks from "./hooks"
|
||||||
|
|
||||||
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
|
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 type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
|
@ -9,7 +8,6 @@ import { State } from "../state"
|
||||||
|
|
||||||
export interface Domains {
|
export interface Domains {
|
||||||
readonly aisdk: AISDKHooks
|
readonly aisdk: AISDKHooks
|
||||||
readonly session: SessionHooks
|
|
||||||
readonly tool: ToolHooks
|
readonly tool: ToolHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) => hooks.register("session", name, callback),
|
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
id: input?.id,
|
id: input?.id,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export * as PluginPromise from "./promise"
|
export * as PluginPromise from "./promise"
|
||||||
|
|
||||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
|
||||||
import { Effect, Scope, Stream } from "effect"
|
import { Effect, Scope, Stream } from "effect"
|
||||||
|
import { Tool } from "../tool/tool"
|
||||||
|
|
||||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||||
type Registration = { readonly dispose: () => Promise<void> }
|
type Registration = { readonly dispose: () => Promise<void> }
|
||||||
|
|
@ -108,7 +110,14 @@ export function fromPromise(plugin: PromisePlugin) {
|
||||||
reload: () => run(host.skill.reload()),
|
reload: () => run(host.skill.reload()),
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
transform: transform(host.tool),
|
transform: (callback) =>
|
||||||
|
register(
|
||||||
|
host.tool.transform((draft) =>
|
||||||
|
callback({
|
||||||
|
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
hook: (name, callback) =>
|
hook: (name, callback) =>
|
||||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
},
|
},
|
||||||
|
|
@ -118,12 +127,24 @@ export function fromPromise(plugin: PromisePlugin) {
|
||||||
prompt: (input) => run(host.session.prompt(input)),
|
prompt: (input) => run(host.session.prompt(input)),
|
||||||
command: (input) => run(host.session.command(input)),
|
command: (input) => run(host.session.command(input)),
|
||||||
interrupt: (input) => run(host.session.interrupt(input)),
|
interrupt: (input) => run(host.session.interrupt(input)),
|
||||||
hook: (name, callback) =>
|
|
||||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||||
|
if (!cleanup) return
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const OpencodeContent = opencodeContent
|
||||||
export const ReportContent = reportContent
|
export const ReportContent = reportContent
|
||||||
|
|
||||||
export const OpencodeDescription =
|
export const OpencodeDescription =
|
||||||
"Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app."
|
"Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, migrating from V1 to V2, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app."
|
||||||
const REPORT_DESCRIPTION =
|
const REPORT_DESCRIPTION =
|
||||||
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
|
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,39 @@ Use this guide as the starting point for work involving OpenCode itself. It
|
||||||
covers the core concepts needed to configure and customize OpenCode, extend it
|
covers the core concepts needed to configure and customize OpenCode, extend it
|
||||||
with plugins, and build integrations with the OpenCode SDK, clients, and API.
|
with plugins, and build integrations with the OpenCode SDK, clients, and API.
|
||||||
|
|
||||||
Full documentation is available at <https://opencode.mintlify.site/>. Consult
|
Full documentation is available at <https://v2.opencode.ai/>. This overview is
|
||||||
it when this overview does not contain enough detail for the task.
|
only an index of core concepts. Before answering a question about a topic below,
|
||||||
|
fetch the URL named in that section and use the full page as the source of
|
||||||
|
truth. Follow links from that page when the question needs more detail. Fetch
|
||||||
|
<https://v2.opencode.ai/llms.txt> first when you need to discover the relevant
|
||||||
|
documentation page.
|
||||||
|
|
||||||
## Configuration
|
## Version policy
|
||||||
|
|
||||||
|
Always answer for OpenCode V2 unless the user explicitly asks about V1,
|
||||||
|
legacy OpenCode, or migrating from V1.
|
||||||
|
|
||||||
|
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
|
||||||
|
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
|
||||||
|
general web search to resolve a V2 documentation question when the V2 docs or
|
||||||
|
their `llms.txt` index cover it. The schema served from
|
||||||
|
<https://opencode.ai/config.json> may describe V1 even though V2 configuration
|
||||||
|
files include that URL for editor integration. Never use it to infer V2 field
|
||||||
|
names or shapes. If V2 documentation is missing or contradictory, state the
|
||||||
|
uncertainty or ask for clarification instead of falling back to V1.
|
||||||
|
|
||||||
|
V1 documentation and syntax may be consulted only when the user explicitly
|
||||||
|
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||||
|
must still use V2 unless the user specifically requests a V1 result.
|
||||||
|
|
||||||
|
## [Configuration](https://v2.opencode.ai/config)
|
||||||
|
|
||||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||||
user's editor can validate fields and provide autocomplete:
|
user's editor can validate fields and provide autocomplete:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json"
|
"$schema": "https://opencode.ai/config.json",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,28 +45,56 @@ to every project for that user. Project configuration can live in any directory
|
||||||
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
||||||
in a monorepo.
|
in a monorepo.
|
||||||
|
|
||||||
When OpenCode starts, it searches upward from the current directory for project
|
When OpenCode starts, it searches from the current directory up to the project
|
||||||
configuration and merges the files it finds with the global configuration.
|
root. It merges direct `opencode.json(c)` files from root to current directory,
|
||||||
|
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||||
|
`.opencode` config overrides every direct config. Global configuration has the
|
||||||
|
lowest precedence.
|
||||||
|
|
||||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||||
`references`, `formatter`, and `lsp`.
|
`references`, `formatter`, and `lsp`.
|
||||||
|
|
||||||
Do not guess field names or shapes. Use
|
Do not guess field names or shapes. Fetch the V2 configuration guide and its
|
||||||
<https://opencode.ai/config.json> as the source of truth and preserve unrelated
|
linked topic guide as the source of truth, and preserve unrelated settings when
|
||||||
settings when editing an existing file.
|
editing an existing file. Keep the published `$schema` URL in configuration
|
||||||
|
examples, but do not fetch it to determine the V2 configuration shape.
|
||||||
|
|
||||||
See the [full configuration guide](https://opencode.mintlify.site/config) for
|
See the [full configuration guide](https://v2.opencode.ai/config) for
|
||||||
every field, examples, config locations, and links to dedicated feature guides.
|
every field, examples, config locations, and links to dedicated feature guides.
|
||||||
|
|
||||||
## Service
|
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
|
||||||
|
|
||||||
|
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||||
|
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||||
|
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
|
||||||
|
the repository, its source is `packages/docs/migrate-v1.mdx`.
|
||||||
|
|
||||||
|
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||||
|
The only intentional breaking changes are the server API and plugin API. Native
|
||||||
|
V2 config uses more ergonomic shapes, but conversion is optional. When the user
|
||||||
|
requests conversion, inspect the complete configuration, preserve behavior and
|
||||||
|
unrelated settings, and apply only the relevant migrations from the guide. For
|
||||||
|
plugin migrations, fetch and follow both the migration guide and the full
|
||||||
|
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
|
||||||
|
functionality fails in V2, use the `report` skill to file it as a compatibility
|
||||||
|
bug.
|
||||||
|
|
||||||
|
## [Plugins](https://v2.opencode.ai/build/plugins)
|
||||||
|
|
||||||
|
For questions about creating, configuring, loading, publishing, or migrating
|
||||||
|
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
|
||||||
|
before answering. This includes questions about the Effect plugin API, hooks,
|
||||||
|
transforms, tools, plugin context capabilities, and package entrypoints.
|
||||||
|
|
||||||
|
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
|
||||||
|
|
||||||
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
|
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
|
||||||
to a background OpenCode service, which owns sessions, configuration, plugins,
|
to a background OpenCode service, which owns sessions, configuration, plugins,
|
||||||
permissions, and tool execution.
|
permissions, and tool execution.
|
||||||
|
|
||||||
Configuration and related files are typically watched and reloaded while the
|
OpenCode normally discovers or starts the shared background service
|
||||||
service is running. If a change does not appear, restart the service:
|
automatically. If the service is stuck or unhealthy, restart it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
opencode2 service restart
|
opencode2 service restart
|
||||||
|
|
@ -56,14 +106,15 @@ Check its status after restarting:
|
||||||
opencode2 service status
|
opencode2 service status
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## [API](https://v2.opencode.ai/api)
|
||||||
|
|
||||||
OpenCode exposes an HTTP API from its server. The API is described by an
|
OpenCode exposes an HTTP API from its server. The API is described by an
|
||||||
OpenAPI document available from the running server at `/openapi.json`.
|
OpenAPI document available from the running server at `/openapi.json`.
|
||||||
|
|
||||||
Use OpenCode's built-in `api` command for local requests. It discovers the same
|
Use OpenCode's built-in `api` command for local requests. It uses the same
|
||||||
background server used by the TUI, starts it when necessary, and applies the
|
discovery and authentication flow as the TUI and may start the background
|
||||||
server's authentication headers automatically.
|
service when no compatible healthy service is available. It accepts either an
|
||||||
|
HTTP method and path or an OpenAPI operation ID.
|
||||||
|
|
||||||
Call an endpoint with an HTTP method and path:
|
Call an endpoint with an HTTP method and path:
|
||||||
|
|
||||||
|
|
@ -84,18 +135,34 @@ connected to an explicit server instead of its managed background service, use
|
||||||
the same configured server and authentication context rather than constructing
|
the same configured server and authentication context rather than constructing
|
||||||
an unauthenticated request separately.
|
an unauthenticated request separately.
|
||||||
|
|
||||||
See the [full API reference](https://opencode.mintlify.site/api) for available
|
See the [full API reference](https://v2.opencode.ai/api) for available
|
||||||
endpoints, parameters, request bodies, and response schemas. The
|
endpoints, parameters, request bodies, and response schemas. The
|
||||||
raw [OpenAPI specification](https://opencode.mintlify.site/openapi.json) is also
|
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
|
||||||
available for code generation and other tooling.
|
available for code generation and other tooling.
|
||||||
|
|
||||||
## Troubleshooting
|
## [Client](https://v2.opencode.ai/build/client)
|
||||||
|
|
||||||
|
For questions about connecting an application to OpenCode over the network,
|
||||||
|
fetch the full [client guide](https://v2.opencode.ai/build/client) before
|
||||||
|
answering.
|
||||||
|
|
||||||
|
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||||
|
API. Its methods and types come from the same contract as the API reference.
|
||||||
|
The default entrypoint exposes Promise-based resource clients and async
|
||||||
|
iterables for streaming endpoints. The `@opencode-ai/client/effect` entrypoint
|
||||||
|
exposes typed Effects, Streams, and decoded OpenCode schema values. Its
|
||||||
|
`Service` API can discover, start, stop, and authenticate with the local
|
||||||
|
background service from a Node application.
|
||||||
|
|
||||||
|
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
|
||||||
|
|
||||||
OpenCode runs a client and a background server. Start by determining whether a
|
OpenCode runs a client and a background server. Start by determining whether a
|
||||||
problem belongs to the client, the shared server, or one project.
|
problem belongs to the client, the shared server, or one project.
|
||||||
|
|
||||||
- Check the service with `opencode2 service status` and verify the API with
|
- Check the service with `opencode2 service status` and verify the API with
|
||||||
`opencode2 api get /api/health`.
|
`opencode2 api get /api/health`.
|
||||||
|
- Compare with `opencode2 --standalone`, which runs the TUI with a private
|
||||||
|
server, to isolate shared-service issues.
|
||||||
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
|
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
|
||||||
client startup and `role=server` for sessions, providers, plugins,
|
client startup and `role=server` for sessions, providers, plugins,
|
||||||
permissions, and tools.
|
permissions, and tools.
|
||||||
|
|
@ -107,6 +174,6 @@ problem belongs to the client, the shared server, or one project.
|
||||||
- Redact API keys, authorization headers, prompts, file contents, and other
|
- Redact API keys, authorization headers, prompts, file contents, and other
|
||||||
sensitive data before sharing diagnostics.
|
sensitive data before sharing diagnostics.
|
||||||
|
|
||||||
See the [full troubleshooting guide](https://opencode.mintlify.site/troubleshooting)
|
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
|
||||||
for service lifecycle commands, API inspection, log locations, explicit server
|
for service lifecycle commands, API inspection, log locations, explicit server
|
||||||
connections, issue-reporting details, and local development paths.
|
connections, issue-reporting details, and local development paths.
|
||||||
|
|
|
||||||
|
|
@ -72,23 +72,6 @@ type Operation =
|
||||||
readonly target: string
|
readonly target: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate =
|
|
||||||
| {
|
|
||||||
readonly type: "definition"
|
|
||||||
readonly definition: Plugin
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly type: "package"
|
|
||||||
readonly specifier: string
|
|
||||||
readonly options: Record<string, unknown>
|
|
||||||
readonly mtime?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConfiguredPackage = {
|
|
||||||
readonly operation: Extract<Operation, { type: "add" }>
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||||
if (typeof input !== "string") {
|
if (typeof input !== "string") {
|
||||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||||
|
|
@ -109,13 +92,14 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
.flatMap((entry) =>
|
.flatMap((entry) =>
|
||||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||||
|
if (operation.type === "remove") return operation
|
||||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||||
const target = operation.target.startsWith("file://")
|
const target = operation.target.startsWith("file://")
|
||||||
? fileURLToPath(operation.target)
|
? fileURLToPath(operation.target)
|
||||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||||
? path.resolve(directory, operation.target)
|
? path.resolve(directory, operation.target)
|
||||||
: operation.target
|
: operation.target
|
||||||
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
|
return { ...operation, target }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||||
|
|
@ -136,91 +120,66 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||||
post: readonly Plugin[],
|
post: readonly Plugin[],
|
||||||
operations: readonly Operation[],
|
operations: readonly Operation[],
|
||||||
) {
|
) {
|
||||||
const plan = apply(pre, post, operations)
|
|
||||||
return yield* load(plan)
|
|
||||||
})
|
|
||||||
|
|
||||||
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
|
|
||||||
const matches = (selector: string, target: string) =>
|
const matches = (selector: string, target: string) =>
|
||||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||||
const plugins = [...pre, ...post]
|
const definitions = [...pre, ...post]
|
||||||
const enabled = new Set(plugins.map((plugin) => plugin.id))
|
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||||
const packages = new Map<string, ConfiguredPackage>()
|
const packages = new Map<string, Plugin>()
|
||||||
|
const plugins = () => [...definitions, ...packages.values()]
|
||||||
|
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (operation.type === "remove") {
|
if (operation.type === "remove") {
|
||||||
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
|
plugins()
|
||||||
packages.forEach((item, target) => {
|
.filter((plugin) => matches(operation.target, plugin.id))
|
||||||
if (matches(operation.target, target)) item.enabled = false
|
.forEach((plugin) => enabled.delete(plugin.id))
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
|
const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
|
||||||
const selectsDefinitions =
|
const selectsPlugins =
|
||||||
matched.length > 0 ||
|
matched.length > 0 ||
|
||||||
operation.target === "*" ||
|
operation.target === "*" ||
|
||||||
operation.target.endsWith(".*") ||
|
operation.target.endsWith(".*") ||
|
||||||
operation.target.startsWith("opencode.")
|
operation.target.startsWith("opencode.")
|
||||||
if (selectsDefinitions) {
|
if (selectsPlugins) {
|
||||||
matched.forEach((plugin) => enabled.add(plugin.id))
|
matched.forEach((plugin) => enabled.add(plugin.id))
|
||||||
packages.forEach((item, target) => {
|
|
||||||
if (matches(operation.target, target)) item.enabled = true
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
packages.set(operation.target, { operation, enabled: true })
|
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||||
|
if (!plugin) continue
|
||||||
|
const previous = packages.get(operation.target)
|
||||||
|
if (previous) enabled.delete(previous.id)
|
||||||
|
packages.set(operation.target, plugin)
|
||||||
|
enabled.add(plugin.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const definitions: Candidate[] = pre.flatMap((definition) =>
|
return [
|
||||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||||
)
|
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||||
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
|
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||||
item.enabled
|
]
|
||||||
? [
|
})
|
||||||
{
|
|
||||||
type: "package",
|
|
||||||
specifier: item.operation.target,
|
|
||||||
options: item.operation.options,
|
|
||||||
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
const posts: Candidate[] = post.flatMap((definition) =>
|
|
||||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
|
||||||
)
|
|
||||||
return [...definitions, ...configured, ...posts]
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
|
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||||
return yield* Effect.forEach(plan, (candidate) => {
|
const npm = yield* Npm.Service
|
||||||
if (candidate.type === "definition") return Effect.succeed({ plugin: candidate.definition })
|
const entrypoint = path.isAbsolute(operation.target)
|
||||||
return Effect.gen(function* () {
|
? pathToFileURL(operation.target).href
|
||||||
const npm = yield* Npm.Service
|
: (yield* npm.add(operation.target)).entrypoint
|
||||||
const entrypoint = path.isAbsolute(candidate.specifier)
|
if (!entrypoint) return
|
||||||
? pathToFileURL(candidate.specifier).href
|
// Bun currently ignores query parameters when caching file:// imports.
|
||||||
: (yield* npm.add(candidate.specifier)).entrypoint
|
const source =
|
||||||
if (!entrypoint) return
|
operation.mtime === undefined
|
||||||
// Bun currently ignores query parameters when caching file:// imports.
|
? entrypoint
|
||||||
const source =
|
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||||
candidate.mtime === undefined
|
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||||
? entrypoint
|
const mod = yield* Effect.promise(() => import(source))
|
||||||
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
|
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||||
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
|
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||||
const mod = yield* Effect.promise(() => import(source))
|
return {
|
||||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
id: plugin.id,
|
||||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||||
return {
|
} satisfies Plugin
|
||||||
plugin: {
|
|
||||||
id: plugin.id,
|
|
||||||
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
|
|
||||||
} satisfies Plugin,
|
|
||||||
...(candidate.mtime === undefined ? {} : { version: String(candidate.mtime) }),
|
|
||||||
}
|
|
||||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
|
||||||
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,16 @@ const layer = Layer.effect(
|
||||||
description: reference.description,
|
description: reference.description,
|
||||||
}))
|
}))
|
||||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
if (available.length === 0) return Instructions.empty
|
return Instructions.make<ReadonlyArray<typeof Summary.Type>>({
|
||||||
return Instructions.make({
|
|
||||||
key: Instructions.Key.make("core/reference-guidance"),
|
key: Instructions.Key.make("core/reference-guidance"),
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(available),
|
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
|
||||||
baseline: render,
|
render: {
|
||||||
update,
|
initial: render,
|
||||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
changed: update,
|
||||||
|
removed: () =>
|
||||||
|
"Project reference guidance is no longer available. Do not use previously listed references.",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionPending } from "./session/pending"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
@ -188,7 +188,13 @@ export interface Interface {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||||
/**
|
/**
|
||||||
* Durable, ordered, gap-free session log read. Replays public durable
|
* Durable admitted session work not yet visible in projected history,
|
||||||
|
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||||
|
* unhandled compaction barriers.
|
||||||
|
*/
|
||||||
|
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||||
|
/**
|
||||||
|
* Durable, ordered session log read. Replays public durable
|
||||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||||
* marker at the captured replay watermark, then continues live when `follow`
|
* marker at the captured replay watermark, then continues live when `follow`
|
||||||
* is set.
|
* is set.
|
||||||
|
|
@ -216,9 +222,9 @@ export interface Interface {
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.User, NotFoundError | PromptConflictError | AttachmentError>
|
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
||||||
readonly command: (input: {
|
readonly command: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|
@ -228,10 +234,10 @@ export interface Interface {
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<
|
}) => Effect.Effect<
|
||||||
SessionInput.User,
|
SessionPending.User,
|
||||||
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
||||||
>
|
>
|
||||||
readonly shell: (input: {
|
readonly shell: (input: {
|
||||||
|
|
@ -247,7 +253,7 @@ export interface Interface {
|
||||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||||
readonly compact: (
|
readonly compact: (
|
||||||
input: CompactInput,
|
input: CompactInput,
|
||||||
) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
|
) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
|
||||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
|
|
@ -259,9 +265,9 @@ export interface Interface {
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.Synthetic, NotFoundError | SyntheticConflictError>
|
}) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|
@ -378,9 +384,11 @@ const layer = Layer.effect(
|
||||||
if (input.messageID && !boundary)
|
if (input.messageID && !boundary)
|
||||||
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
|
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
|
||||||
const sessionID = SessionSchema.ID.create()
|
const sessionID = SessionSchema.ID.create()
|
||||||
|
const parentSeq = boundary ? boundary.seq - 1 : yield* EventV2.latestSequence(db, parent.id)
|
||||||
yield* events.publish(SessionEvent.Forked, {
|
yield* events.publish(SessionEvent.Forked, {
|
||||||
sessionID,
|
sessionID,
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
|
parentSeq,
|
||||||
from: input.messageID,
|
from: input.messageID,
|
||||||
})
|
})
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
|
|
@ -481,6 +489,10 @@ const layer = Layer.effect(
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* store.context(sessionID)
|
return yield* store.context(sessionID)
|
||||||
}),
|
}),
|
||||||
|
pending: Effect.fn("V2Session.pending")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* SessionPending.list(db, sessionID)
|
||||||
|
}),
|
||||||
log: (input) =>
|
log: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
|
|
@ -504,25 +516,25 @@ const layer = Layer.effect(
|
||||||
Effect.provideService(FSUtil.Service, fs),
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
)
|
)
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const admittedInput = SessionInput.Message.make({
|
const admittedInput = SessionPending.Message.make({
|
||||||
type: "user",
|
type: "user",
|
||||||
data: { ...prompt, metadata: input.metadata },
|
data: { ...prompt, metadata: input.metadata },
|
||||||
delivery: input.delivery ?? "steer",
|
delivery: input.delivery ?? "steer",
|
||||||
})
|
})
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
input: admittedInput,
|
input: admittedInput,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
admitted.type !== "user" ||
|
admitted.type !== "user" ||
|
||||||
!SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
||||||
)
|
)
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
if (input.resume !== false) {
|
if (input.resume !== false) {
|
||||||
|
|
@ -571,7 +583,7 @@ const layer = Layer.effect(
|
||||||
yield* shellLocks.withLock(input.sessionID)(
|
yield* shellLocks.withLock(input.sessionID)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
activeShells.add(input.sessionID)
|
activeShells.add(input.sessionID)
|
||||||
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
|
yield* execution.awaitIdle(input.sessionID)
|
||||||
const started = yield* Effect.gen(function* () {
|
const started = yield* Effect.gen(function* () {
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
|
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
|
||||||
|
|
@ -664,12 +676,12 @@ const layer = Layer.effect(
|
||||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const inputID = input.id ?? SessionMessage.ID.create()
|
const inputID = input.id ?? SessionMessage.ID.create()
|
||||||
const admitted = yield* SessionInput.admitCompaction(db, events, {
|
const admitted = yield* SessionPending.admitCompaction(db, events, {
|
||||||
id: inputID,
|
id: inputID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
|
|
@ -709,7 +721,7 @@ const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const inputID = input.id ?? SessionMessage.ID.create()
|
const inputID = input.id ?? SessionMessage.ID.create()
|
||||||
const admittedInput = SessionInput.Message.make({
|
const admittedInput = SessionPending.Message.make({
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
data: {
|
data: {
|
||||||
text: input.text,
|
text: input.text,
|
||||||
|
|
@ -718,20 +730,20 @@ const layer = Layer.effect(
|
||||||
},
|
},
|
||||||
delivery: input.delivery ?? "steer",
|
delivery: input.delivery ?? "steer",
|
||||||
})
|
})
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id: inputID,
|
id: inputID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
input: admittedInput,
|
input: admittedInput,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
admitted.type !== "synthetic" ||
|
admitted.type !== "synthetic" ||
|
||||||
!SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
||||||
)
|
)
|
||||||
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
||||||
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
|
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
export * as SessionCompaction from "./compaction"
|
export * as SessionCompaction from "./compaction"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
|
@ -10,12 +11,12 @@ import { SessionEvent } from "./event"
|
||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
import { toSessionError } from "./to-session-error"
|
||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 8_000
|
const DEFAULT_KEEP_TOKENS = 8_000
|
||||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||||
const SUMMARY_OUTPUT_TOKENS = 4_096
|
|
||||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
<template>
|
<template>
|
||||||
## Objective
|
## Objective
|
||||||
|
|
@ -59,20 +60,14 @@ type Dependencies = {
|
||||||
readonly llm: {
|
readonly llm: {
|
||||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
readonly config: readonly Config.Entry[]
|
readonly models: SessionRunnerModel.Interface
|
||||||
|
readonly config: Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutoInput = {
|
export type AutoInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
|
||||||
readonly request: LLMRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompactInput = {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
readonly inputID?: SessionMessage.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManualInput = {
|
export type ManualInput = {
|
||||||
|
|
@ -81,16 +76,27 @@ export type ManualInput = {
|
||||||
readonly inputID: SessionMessage.ID
|
readonly inputID: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Plan = {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly model: Model
|
||||||
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
|
readonly prompt: string
|
||||||
|
readonly recent: string
|
||||||
|
readonly inputID?: SessionMessage.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Outcome =
|
||||||
|
| Pick<SessionMessage.CompactionCompleted, "status">
|
||||||
|
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
|
readonly required: (input: AutoInput) => boolean
|
||||||
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
|
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||||
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
|
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
||||||
|
|
||||||
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
|
||||||
|
|
||||||
const truncate = (value: string) =>
|
const truncate = (value: string) =>
|
||||||
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
||||||
|
|
||||||
|
|
@ -153,31 +159,34 @@ const select = (
|
||||||
tokens: number,
|
tokens: number,
|
||||||
): { readonly head: string; readonly recent: string } | undefined => {
|
): { readonly head: string; readonly recent: string } | undefined => {
|
||||||
const conversation = messages
|
const conversation = messages
|
||||||
.filter((message) => message.type !== "compaction")
|
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||||
.map(serialize)
|
.flatMap((message) => {
|
||||||
.filter(Boolean)
|
const text = serialize(message)
|
||||||
|
return text ? [{ message, text }] : []
|
||||||
|
})
|
||||||
if (conversation.length === 0) return undefined
|
if (conversation.length === 0) return undefined
|
||||||
let total = 0
|
let total = 0
|
||||||
let split = conversation.length
|
let split = conversation.length
|
||||||
let splitPrefix = ""
|
|
||||||
let splitSuffix = ""
|
|
||||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||||
const next = total + Token.estimate(conversation[index])
|
const next = total + Token.estimate(conversation[index].text)
|
||||||
if (next > tokens) {
|
if (split < conversation.length && next > tokens) break
|
||||||
const remaining = Math.max(0, tokens - total) * 4
|
|
||||||
if (remaining > 0) {
|
|
||||||
splitPrefix = conversation[index].slice(0, -remaining)
|
|
||||||
splitSuffix = conversation[index].slice(-remaining)
|
|
||||||
split = index + 1
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
total = next
|
total = next
|
||||||
split = index
|
split = index
|
||||||
}
|
}
|
||||||
|
while (split > 0 && conversation[split].message.type !== "user") split--
|
||||||
|
if (split === 0) {
|
||||||
|
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
|
||||||
|
if (latestUser > 0) split = latestUser
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
|
head: conversation
|
||||||
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
|
.slice(0, split)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n\n"),
|
||||||
|
recent: conversation
|
||||||
|
.slice(split)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n\n"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,146 +199,166 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
||||||
...input.context,
|
...input.context,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
|
|
||||||
|
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||||
|
const selected = select(messages, tokens)
|
||||||
|
if (!selected) return
|
||||||
|
const previousSummary = messages.findLast(
|
||||||
|
(message) => message.type === "compaction" && message.status === "completed",
|
||||||
|
)
|
||||||
|
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
||||||
|
const summarizeRecent = !previousRecent && !selected.head
|
||||||
|
return {
|
||||||
|
prompt: buildPrompt({
|
||||||
|
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||||
|
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||||
|
}),
|
||||||
|
recent: summarizeRecent ? "" : selected.recent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const make = (dependencies: Dependencies) => {
|
const make = (dependencies: Dependencies) => {
|
||||||
const config = settings(dependencies.config)
|
const config = dependencies.config
|
||||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
const failed = Effect.fnUntraced(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly model: Model
|
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
readonly previousSummary?: string
|
readonly error: SessionError.Error
|
||||||
readonly context: readonly string[]
|
|
||||||
readonly recent: string
|
|
||||||
readonly output?: number
|
|
||||||
readonly inputID?: SessionMessage.ID
|
readonly inputID?: SessionMessage.ID
|
||||||
}) {
|
}) {
|
||||||
const context = input.model.route.defaults.limits?.context
|
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, input)
|
||||||
if (context === undefined || context <= 0) return false
|
return { status: "failed" as const, error: input.error }
|
||||||
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
})
|
||||||
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
|
||||||
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
|
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
recent: input.recent,
|
recent: plan.recent,
|
||||||
inputID: input.inputID,
|
inputID: plan.inputID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
let failure: SessionError.Error | undefined
|
||||||
const summarized = yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: input.model,
|
model: plan.model,
|
||||||
messages: [Message.user(summaryPrompt)],
|
messages: [Message.user(plan.prompt)],
|
||||||
tools: [],
|
tools: [],
|
||||||
generation: { maxTokens: summaryOutput },
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event))
|
||||||
|
failure = {
|
||||||
|
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||||
|
message: event.message,
|
||||||
|
}
|
||||||
if (LLMEvent.is.textDelta(event)) {
|
if (LLMEvent.is.textDelta(event)) {
|
||||||
chunks.push(event.text)
|
chunks.push(event.text)
|
||||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
text: event.text,
|
text: event.text,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.catchTag("LLM.Error", (error) =>
|
||||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
Effect.sync(() => {
|
||||||
|
failure = toSessionError(error)
|
||||||
|
}),
|
||||||
|
),
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
input.reason === "auto"
|
plan.reason === "auto"
|
||||||
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
? failed({
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||||
inputID: input.inputID,
|
inputID: plan.inputID,
|
||||||
})
|
}).pipe(Effect.asVoid)
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const summary = chunks.join("")
|
const summary = chunks.join("")
|
||||||
if (!summarized || failed || !summary.trim()) {
|
if (failure || !summary.trim()) {
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||||
sessionID: input.sessionID,
|
return yield* failed({
|
||||||
reason: input.reason,
|
sessionID: plan.sessionID,
|
||||||
error: { type: "compaction.failed", message: "Compaction produced no summary" },
|
reason: plan.reason,
|
||||||
inputID: input.inputID,
|
error,
|
||||||
|
inputID: plan.inputID,
|
||||||
})
|
})
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
text: summary,
|
text: summary,
|
||||||
recent: input.recent,
|
recent: plan.recent,
|
||||||
})
|
})
|
||||||
return true
|
return { status: "completed" as const }
|
||||||
})
|
})
|
||||||
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
|
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||||
return yield* compactSelected({
|
const content = planContent(input.messages, config.tokens)
|
||||||
|
if (content)
|
||||||
|
return yield* execute({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
model: input.model,
|
||||||
|
reason: "auto",
|
||||||
|
...content,
|
||||||
|
})
|
||||||
|
const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" }
|
||||||
|
return yield* failed({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messages: input.messages,
|
|
||||||
model: input.request.model,
|
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
force: false,
|
error,
|
||||||
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
|
const required = (input: AutoInput) => {
|
||||||
input: CompactInput & {
|
if (!config.auto) return false
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
|
||||||
readonly force: boolean
|
|
||||||
readonly output?: number
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const context = input.model.route.defaults.limits?.context
|
const context = input.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
const selected = select(input.messages, config.tokens)
|
const last = input.messages.findLast(
|
||||||
if (!selected) return false
|
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||||
const previousSummary = input.messages.find(
|
message.type === "assistant" && message.tokens !== undefined,
|
||||||
(message) => message.type === "compaction" && message.status === "completed",
|
|
||||||
)
|
)
|
||||||
const hasHead = selected.head.length > 0
|
if (!last) return false
|
||||||
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
|
const output = input.model.route.defaults.limits?.output ?? 0
|
||||||
const forcedShortContext = input.force && !hasHead
|
const used =
|
||||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
return yield* compact({
|
if (used <= 0) return false
|
||||||
sessionID: input.sessionID,
|
return used >= context - (output || config.buffer)
|
||||||
model: input.model,
|
}
|
||||||
reason: input.reason,
|
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
const content = planContent(input.messages, config.tokens)
|
||||||
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
if (!content)
|
||||||
Boolean,
|
return yield* failed({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
reason: "manual",
|
||||||
|
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||||
|
inputID: input.inputID,
|
||||||
|
})
|
||||||
|
const resolved = yield* dependencies.models.resolve(input.session).pipe(
|
||||||
|
Effect.catch((cause) =>
|
||||||
|
failed({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
reason: "manual",
|
||||||
|
error: toSessionError(cause),
|
||||||
|
inputID: input.inputID,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
recent: forcedShortContext ? "" : selected.recent,
|
)
|
||||||
output: input.output,
|
if ("status" in resolved) return resolved
|
||||||
|
return yield* execute({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
model: resolved.model,
|
||||||
|
reason: "manual",
|
||||||
inputID: input.inputID,
|
inputID: input.inputID,
|
||||||
|
...content,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
return Service.of({
|
||||||
return yield* compactSelected({ ...input, reason: "manual", force: true })
|
required,
|
||||||
})
|
compact,
|
||||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
|
||||||
if (!config.auto) return false
|
|
||||||
const context = input.request.model.route.defaults.limits?.context
|
|
||||||
if (context === undefined || context <= 0) return false
|
|
||||||
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
|
|
||||||
if (
|
|
||||||
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
|
||||||
context - Math.max(output, config.buffer)
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
return yield* compactAfterOverflow(input)
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
compactIfNeeded,
|
|
||||||
compactAfterOverflow,
|
|
||||||
compactManual,
|
compactManual,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
|
|
@ -339,22 +368,7 @@ export const layer = Layer.effect(
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const compaction = make({ events, llm, config: yield* config.entries() })
|
return make({ events, llm, models, config: settings(yield* config.entries()) })
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
compactIfNeeded: compaction.compactIfNeeded,
|
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
|
||||||
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!resolved) return false
|
|
||||||
return yield* compaction.compactManual({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
messages: input.messages,
|
|
||||||
model: resolved.model,
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { MessageDecodeError } from "./error"
|
import { MessageDecodeError } from "./error"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
|
import { Instructions } from "../instructions/index"
|
||||||
|
import { InstructionState } from "./instruction-state"
|
||||||
|
import { SessionMessageTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
|
|
@ -31,7 +33,6 @@ const messageRows = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
compaction: { readonly seq: number } | undefined,
|
compaction: { readonly seq: number } | undefined,
|
||||||
baselineSeq?: number,
|
|
||||||
) {
|
) {
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -39,20 +40,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionMessageTable.session_id, sessionID),
|
eq(SessionMessageTable.session_id, sessionID),
|
||||||
// Keep system updates visible in the gap between a completed compaction
|
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||||
// and the next prepared step's rebaseline, when their content is not yet
|
|
||||||
// folded into a new baseline.
|
|
||||||
compaction
|
|
||||||
? or(
|
|
||||||
gte(SessionMessageTable.seq, compaction.seq),
|
|
||||||
baselineSeq === undefined
|
|
||||||
? undefined
|
|
||||||
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
baselineSeq === undefined
|
|
||||||
? undefined
|
|
||||||
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(SessionMessageTable.seq))
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
|
@ -73,30 +61,32 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
const [epoch, compaction] = yield* Effect.all(
|
return yield* Effect.forEach(
|
||||||
[
|
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
|
||||||
db
|
decodeMessageRow,
|
||||||
.select({ baselineSeq: InstructionCheckpointTable.baseline_seq })
|
|
||||||
.from(InstructionCheckpointTable)
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie),
|
|
||||||
latestCompaction(db, sessionID),
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
)
|
||||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
baselineSeq: number,
|
instructions: Instructions.Instructions,
|
||||||
) {
|
) {
|
||||||
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
|
return yield* db
|
||||||
return yield* Effect.forEach(rows, (row) =>
|
.transaction(() =>
|
||||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
Effect.gen(function* () {
|
||||||
)
|
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
|
||||||
|
const messages = yield* Effect.forEach(rows, (row) =>
|
||||||
|
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||||
|
)
|
||||||
|
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
||||||
|
return {
|
||||||
|
initial: assembled.initial,
|
||||||
|
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
export * as InstructionCheckpoint from "./instruction-checkpoint"
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
import { Effect, Option, Schema } from "effect"
|
|
||||||
import type { Database } from "../database/database"
|
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { Instructions } from "../instructions/index"
|
|
||||||
import { SessionEvent } from "./event"
|
|
||||||
import { SessionHistory } from "./history"
|
|
||||||
import { SessionSchema } from "./schema"
|
|
||||||
import { InstructionCheckpointTable } from "./sql"
|
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
|
||||||
|
|
||||||
const decodeApplied = Schema.decodeUnknownOption(Instructions.Applied)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads or creates the session's durable instruction checkpoint, narrating any
|
|
||||||
* drift since the model was last told as a chronological update. Completed
|
|
||||||
* compaction rebaselines; nothing else rewrites the baseline. Runs before
|
|
||||||
* input promotion so a blocked first step leaves pending inputs untouched.
|
|
||||||
*/
|
|
||||||
export const prepare = Effect.fn("InstructionCheckpoint.prepare")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
events: EventV2.Interface,
|
|
||||||
instructions: Effect.Effect<Instructions.Instructions>,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
) {
|
|
||||||
const [value, stored, compaction] = yield* Effect.all(
|
|
||||||
[instructions, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
if (!stored) {
|
|
||||||
const baseline = yield* Instructions.initialize(value)
|
|
||||||
const baselineSeq = yield* insert(db, sessionID, baseline)
|
|
||||||
return { baseline: baseline.text, baselineSeq }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The applied record is comparison state only; an undecodable one heals by
|
|
||||||
// treating every source as new, re-announcing baselines as updates.
|
|
||||||
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
|
|
||||||
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
|
|
||||||
const baseline = yield* Instructions.rebaseline(value, applied)
|
|
||||||
yield* rewrite(db, sessionID, compaction.seq, baseline)
|
|
||||||
return { baseline: baseline.text, baselineSeq: compaction.seq }
|
|
||||||
}
|
|
||||||
const result = yield* Instructions.reconcile(value, applied)
|
|
||||||
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
|
||||||
|
|
||||||
yield* events.publish(
|
|
||||||
SessionEvent.InstructionsUpdated,
|
|
||||||
{ sessionID, text: result.text },
|
|
||||||
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
|
|
||||||
)
|
|
||||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
|
||||||
})
|
|
||||||
|
|
||||||
export const reset = Effect.fn("InstructionCheckpoint.reset")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
) {
|
|
||||||
yield* db
|
|
||||||
.delete(InstructionCheckpointTable)
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
return yield* db
|
|
||||||
.select()
|
|
||||||
.from(InstructionCheckpointTable)
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const insert = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
baseline: Instructions.Baseline,
|
|
||||||
) {
|
|
||||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
|
||||||
yield* db
|
|
||||||
.insert(InstructionCheckpointTable)
|
|
||||||
.values({
|
|
||||||
session_id: sessionID,
|
|
||||||
baseline: baseline.text,
|
|
||||||
snapshot: baseline.applied,
|
|
||||||
baseline_seq: baselineSeq,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return baselineSeq
|
|
||||||
})
|
|
||||||
|
|
||||||
const rewrite = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
baselineSeq: number,
|
|
||||||
baseline: Instructions.Baseline,
|
|
||||||
) {
|
|
||||||
const updated = yield* db
|
|
||||||
.update(InstructionCheckpointTable)
|
|
||||||
.set({
|
|
||||||
baseline: baseline.text,
|
|
||||||
snapshot: baseline.applied,
|
|
||||||
baseline_seq: baselineSeq,
|
|
||||||
})
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
|
||||||
.returning({ sessionID: InstructionCheckpointTable.session_id })
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const advance = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
applied: Instructions.Applied,
|
|
||||||
) {
|
|
||||||
const updated = yield* db
|
|
||||||
.update(InstructionCheckpointTable)
|
|
||||||
.set({ snapshot: applied })
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
|
||||||
.returning({ sessionID: InstructionCheckpointTable.session_id })
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
|
|
||||||
})
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as InstructionEntry from "./instruction-entry"
|
export * as InstructionEntry from "./instruction-entry"
|
||||||
|
|
||||||
import { and, asc, eq } from "drizzle-orm"
|
import { and, asc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
|
|
@ -13,6 +13,8 @@ export const Key = InstructionEntry.Key
|
||||||
export type Key = typeof Key.Type
|
export type Key = typeof Key.Type
|
||||||
export const Info = InstructionEntry.Info
|
export const Info = InstructionEntry.Info
|
||||||
export type Info = typeof Info.Type
|
export type Info = typeof Info.Type
|
||||||
|
export const MaxValueBytes = InstructionEntry.MaxValueBytes
|
||||||
|
export const ValueTooLargeError = InstructionEntry.ValueTooLargeError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
||||||
|
|
@ -20,7 +22,7 @@ export interface Interface {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}) => Effect.Effect<void>
|
}) => Effect.Effect<void, InstructionEntry.ValueTooLargeError>
|
||||||
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
|
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
|
||||||
/** Produces one Instructions source per stored entry, keyed `api/<key>`. */
|
/** Produces one Instructions source per stored entry, keyed `api/<key>`. */
|
||||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
|
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
|
||||||
|
|
@ -35,18 +37,20 @@ const renderBlock = (key: Key, value: Schema.Json) =>
|
||||||
|
|
||||||
// Rendering stays mechanism-neutral: the model sees session context, not how
|
// Rendering stays mechanism-neutral: the model sees session context, not how
|
||||||
// it was attached. Only chronological updates and removals carry narration.
|
// it was attached. Only chronological updates and removals carry narration.
|
||||||
const source = (entry: Info) =>
|
const source = (entry: Info & { readonly removed: boolean }) =>
|
||||||
Instructions.make({
|
Instructions.make<Schema.Json>({
|
||||||
key: Instructions.Key.make(`api/${entry.key}`),
|
key: Instructions.Key.make(`api/${entry.key}`),
|
||||||
codec: Schema.toCodecJson(Schema.Json),
|
codec: Schema.toCodecJson(Schema.Json),
|
||||||
load: Effect.succeed(entry.value),
|
read: Effect.succeed(entry.removed ? Instructions.removed : entry.value),
|
||||||
baseline: (value) => renderBlock(entry.key, value),
|
render: {
|
||||||
update: (_previous, value) =>
|
initial: (value) => renderBlock(entry.key, value),
|
||||||
[
|
changed: (_previous, value) =>
|
||||||
`The context under "${entry.key}" changed and supersedes the previous value:`,
|
[
|
||||||
renderBlock(entry.key, value),
|
`The context under "${entry.key}" changed and supersedes the previous value:`,
|
||||||
].join("\n"),
|
renderBlock(entry.key, value),
|
||||||
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
|
].join("\n"),
|
||||||
|
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
|
|
@ -54,15 +58,27 @@ const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
|
const rows = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, includeRemoved: boolean) {
|
||||||
const rows = yield* db
|
return yield* db
|
||||||
.select()
|
.select({
|
||||||
|
key: InstructionEntryTable.key,
|
||||||
|
value: InstructionEntryTable.value,
|
||||||
|
removed: InstructionEntryTable.removed,
|
||||||
|
})
|
||||||
.from(InstructionEntryTable)
|
.from(InstructionEntryTable)
|
||||||
.where(eq(InstructionEntryTable.session_id, sessionID))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(InstructionEntryTable.session_id, sessionID),
|
||||||
|
includeRemoved ? undefined : eq(InstructionEntryTable.removed, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(asc(InstructionEntryTable.key))
|
.orderBy(asc(InstructionEntryTable.key))
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return rows.map((row) => ({ key: row.key, value: row.value }))
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
|
||||||
|
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
||||||
|
|
@ -70,12 +86,24 @@ const layer = Layer.effect(
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}) {
|
}) {
|
||||||
|
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||||
|
if (actualBytes > MaxValueBytes)
|
||||||
|
yield* new ValueTooLargeError({
|
||||||
|
actualBytes,
|
||||||
|
maxBytes: MaxValueBytes,
|
||||||
|
message: `Instruction entry value is ${actualBytes} bytes; the limit is ${MaxValueBytes} bytes`,
|
||||||
|
})
|
||||||
|
const changed =
|
||||||
|
input.value === null
|
||||||
|
? isNotNull(InstructionEntryTable.value)
|
||||||
|
: or(isNull(InstructionEntryTable.value), ne(InstructionEntryTable.value, input.value))
|
||||||
yield* db
|
yield* db
|
||||||
.insert(InstructionEntryTable)
|
.insert(InstructionEntryTable)
|
||||||
.values({ session_id: input.sessionID, key: input.key, value: input.value })
|
.values({ session_id: input.sessionID, key: input.key, value: input.value, removed: false })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [InstructionEntryTable.session_id, InstructionEntryTable.key],
|
target: [InstructionEntryTable.session_id, InstructionEntryTable.key],
|
||||||
set: { value: input.value, time_updated: Date.now() },
|
set: { value: input.value, removed: false, time_updated: Date.now() },
|
||||||
|
setWhere: or(eq(InstructionEntryTable.removed, true), changed),
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -86,15 +114,21 @@ const layer = Layer.effect(
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
}) {
|
}) {
|
||||||
yield* db
|
yield* db
|
||||||
.delete(InstructionEntryTable)
|
.update(InstructionEntryTable)
|
||||||
.where(and(eq(InstructionEntryTable.session_id, input.sessionID), eq(InstructionEntryTable.key, input.key)))
|
.set({ value: null, removed: true, time_updated: Date.now() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(InstructionEntryTable.session_id, input.sessionID),
|
||||||
|
eq(InstructionEntryTable.key, input.key),
|
||||||
|
eq(InstructionEntryTable.removed, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const load = Effect.fn("InstructionEntry.load")(function* (sessionID: SessionSchema.ID) {
|
const load = Effect.fn("InstructionEntry.load")(function* (sessionID: SessionSchema.ID) {
|
||||||
const entries = yield* list(sessionID)
|
return Instructions.combine((yield* rows(sessionID, true)).map(source))
|
||||||
return Instructions.combine(entries.map(source))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ list, put, remove, load })
|
return Service.of({ list, put, remove, load })
|
||||||
|
|
|
||||||
338
packages/core/src/session/instruction-state.ts
Normal file
338
packages/core/src/session/instruction-state.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
export * as InstructionState from "./instruction-state"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
||||||
|
import { DateTime, Effect, Option, Schema } from "effect"
|
||||||
|
import type { Database } from "../database/database"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
|
import { EventTable } from "../event/sql"
|
||||||
|
import { Instructions } from "../instructions/index"
|
||||||
|
import { SessionEvent } from "./event"
|
||||||
|
import { SessionMessage } from "./message"
|
||||||
|
import { SessionSchema } from "./schema"
|
||||||
|
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
|
||||||
|
|
||||||
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
|
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
||||||
|
|
||||||
|
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
events: EventV2.Interface,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
) {
|
||||||
|
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
})
|
||||||
|
const admission = yield* Instructions.diff(observed, stored?.current_values)
|
||||||
|
if (!stored || Object.keys(admission.delta).length > 0) {
|
||||||
|
yield* events.publish(
|
||||||
|
SessionEvent.InstructionsUpdated,
|
||||||
|
{ sessionID, delta: admission.delta },
|
||||||
|
{
|
||||||
|
commit: () => insertBlobs(db, admission.blobs),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
seq: number,
|
||||||
|
delta: Instructions.Delta,
|
||||||
|
) {
|
||||||
|
const stored = yield* find(db, sessionID)
|
||||||
|
const current = Instructions.applyHashDelta(stored?.current_values ?? {}, delta)
|
||||||
|
if (!stored) {
|
||||||
|
yield* db
|
||||||
|
.insert(InstructionStateTable)
|
||||||
|
.values({
|
||||||
|
session_id: sessionID,
|
||||||
|
epoch_start: seq,
|
||||||
|
through_seq: seq,
|
||||||
|
initial_values: current,
|
||||||
|
current_values: current,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* db
|
||||||
|
.update(InstructionStateTable)
|
||||||
|
.set({ through_seq: seq, current_values: current })
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
epochStart: number,
|
||||||
|
) {
|
||||||
|
yield* db
|
||||||
|
.update(InstructionStateTable)
|
||||||
|
.set({
|
||||||
|
epoch_start: epochStart,
|
||||||
|
through_seq: epochStart,
|
||||||
|
initial_values: sql`${InstructionStateTable.current_values}`,
|
||||||
|
})
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reset = Effect.fn("InstructionState.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
yield* db
|
||||||
|
.delete(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
) {
|
||||||
|
const folded = fold(yield* instructionEvents(db, sessionID))
|
||||||
|
if (!folded) {
|
||||||
|
yield* reset(db, sessionID)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
session_id: sessionID,
|
||||||
|
epoch_start: folded.epochStart,
|
||||||
|
through_seq: folded.throughSeq,
|
||||||
|
initial_values: folded.initial,
|
||||||
|
current_values: folded.current,
|
||||||
|
}
|
||||||
|
yield* db
|
||||||
|
.insert(InstructionStateTable)
|
||||||
|
.values(state)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: InstructionStateTable.session_id,
|
||||||
|
set: {
|
||||||
|
epoch_start: folded.epochStart,
|
||||||
|
through_seq: folded.throughSeq,
|
||||||
|
initial_values: folded.initial,
|
||||||
|
current_values: folded.current,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
|
||||||
|
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
) {
|
||||||
|
const state = yield* find(db, sessionID)
|
||||||
|
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||||
|
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
||||||
|
const updates = rows.map((row) => ({
|
||||||
|
row,
|
||||||
|
delta: decodeInstructionsUpdated(row.data).delta,
|
||||||
|
}))
|
||||||
|
const blobs = yield* loadBlobs(db, [
|
||||||
|
...Object.values(state.initial_values),
|
||||||
|
...updates.flatMap((update) =>
|
||||||
|
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
const valuesAtStart = dereference(state.initial_values, blobs)
|
||||||
|
let values = valuesAtStart
|
||||||
|
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
|
||||||
|
for (const update of updates) {
|
||||||
|
const delta = dereferenceDelta(update.delta, blobs)
|
||||||
|
const text = Instructions.renderUpdate(instructions, values, delta)
|
||||||
|
if (text.length > 0)
|
||||||
|
result.push({
|
||||||
|
seq: update.row.seq,
|
||||||
|
message: SessionMessage.System.make({
|
||||||
|
id: SessionMessage.ID.fromEvent(EventV2.ID.make(update.row.id)),
|
||||||
|
type: "system",
|
||||||
|
text,
|
||||||
|
time: { created: DateTime.makeUnsafe(update.row.created) },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
values = Instructions.applyDelta(values, delta)
|
||||||
|
}
|
||||||
|
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
|
||||||
|
})
|
||||||
|
|
||||||
|
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
return yield* db
|
||||||
|
.select()
|
||||||
|
.from(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const stored = yield* find(db, sessionID)
|
||||||
|
if (!stored) return yield* rebuild(db, sessionID)
|
||||||
|
const latest = yield* db
|
||||||
|
.select({ seq: EventTable.seq })
|
||||||
|
.from(EventTable)
|
||||||
|
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
||||||
|
.orderBy(desc(EventTable.seq))
|
||||||
|
.limit(1)
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||||
|
return yield* rebuild(db, sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||||
|
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
||||||
|
if (rows.length === 0) return
|
||||||
|
yield* db.insert(InstructionBlobTable).values(rows).onConflictDoNothing().run().pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadBlobs = Effect.fnUntraced(function* (db: DatabaseService, values: ReadonlyArray<Instructions.Hash>) {
|
||||||
|
const hashes = [...new Set(values)]
|
||||||
|
const batches = Array.from({ length: Math.ceil(hashes.length / 500) }, (_, index) =>
|
||||||
|
hashes.slice(index * 500, (index + 1) * 500),
|
||||||
|
)
|
||||||
|
const rows = (yield* Effect.forEach(
|
||||||
|
batches,
|
||||||
|
(batch) =>
|
||||||
|
db.select().from(InstructionBlobTable).where(inArray(InstructionBlobTable.hash, batch)).all().pipe(Effect.orDie),
|
||||||
|
{ concurrency: 4 },
|
||||||
|
)).flat()
|
||||||
|
const blobs = new Map(rows.map((row) => [row.hash, row.value]))
|
||||||
|
for (const hash of hashes) {
|
||||||
|
if (!blobs.has(hash)) return yield* Effect.die(new Error(`Instruction blob not found: ${hash}`))
|
||||||
|
}
|
||||||
|
return blobs
|
||||||
|
})
|
||||||
|
|
||||||
|
function dereference(values: Instructions.Values, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
|
||||||
|
return Object.fromEntries(Object.entries(values).map(([key, hash]) => [key, requireBlob(blobs, hash)])) as Readonly<
|
||||||
|
Record<string, Schema.Json>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
function dereferenceDelta(delta: Instructions.Delta, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(delta).map(([key, hash]) => [
|
||||||
|
key,
|
||||||
|
hash === "removed" ? Option.none() : Option.some(requireBlob(blobs, hash)),
|
||||||
|
]),
|
||||||
|
) as Readonly<Record<string, Option.Option<Schema.Json>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: Instructions.Hash) {
|
||||||
|
const value = blobs.get(hash)
|
||||||
|
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructionEventType = EventV2.versionedType(
|
||||||
|
SessionEvent.InstructionsUpdated.type,
|
||||||
|
SessionEvent.InstructionsUpdated.durable.version,
|
||||||
|
)
|
||||||
|
const compactionEventType = EventV2.versionedType(
|
||||||
|
SessionEvent.Compaction.Ended.type,
|
||||||
|
SessionEvent.Compaction.Ended.durable.version,
|
||||||
|
)
|
||||||
|
const movedEventType = EventV2.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
|
||||||
|
const revertedEventType = EventV2.versionedType(
|
||||||
|
SessionEvent.RevertEvent.Committed.type,
|
||||||
|
SessionEvent.RevertEvent.Committed.durable.version,
|
||||||
|
)
|
||||||
|
const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType]
|
||||||
|
|
||||||
|
type InstructionEventRow = typeof EventTable.$inferSelect
|
||||||
|
|
||||||
|
const instructionEvents = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||||
|
return yield* eventRows(db, sessionID, relevantEventTypes)
|
||||||
|
})
|
||||||
|
|
||||||
|
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
after: number,
|
||||||
|
) {
|
||||||
|
return yield* eventRows(db, sessionID, [instructionEventType], after)
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventRows = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
types: ReadonlyArray<string>,
|
||||||
|
after?: number,
|
||||||
|
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||||
|
const segments = (yield* lineage(db, sessionID)).filter(
|
||||||
|
(segment) => after === undefined || segment.through === undefined || segment.through > after,
|
||||||
|
)
|
||||||
|
return (yield* Effect.forEach(segments, (segment) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(EventTable.aggregate_id, segment.sessionID),
|
||||||
|
inArray(EventTable.type, types),
|
||||||
|
segment.through === undefined ? undefined : lte(EventTable.seq, segment.through),
|
||||||
|
after === undefined ? undefined : gt(EventTable.seq, after),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(EventTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
)).flat()
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineage = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
through?: number,
|
||||||
|
): Effect.fn.Return<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
|
||||||
|
const session = yield* db
|
||||||
|
.select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const inherited =
|
||||||
|
session?.parentID && session.forkSeq !== null
|
||||||
|
? yield* lineage(
|
||||||
|
db,
|
||||||
|
session.parentID,
|
||||||
|
through === undefined ? session.forkSeq : Math.min(session.forkSeq, through),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }]
|
||||||
|
})
|
||||||
|
|
||||||
|
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
||||||
|
return rows.reduce<
|
||||||
|
| {
|
||||||
|
readonly epochStart: number
|
||||||
|
readonly throughSeq: number
|
||||||
|
readonly initial: Instructions.Values
|
||||||
|
readonly current: Instructions.Values
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>((state, row) => {
|
||||||
|
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
||||||
|
if (row.type === compactionEventType)
|
||||||
|
return state
|
||||||
|
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
|
||||||
|
: undefined
|
||||||
|
if (row.type !== instructionEventType) return state
|
||||||
|
const delta = decodeInstructionsUpdated(row.data).delta
|
||||||
|
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
|
||||||
|
return state
|
||||||
|
? { ...state, throughSeq: row.seq, current }
|
||||||
|
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||||
|
}, undefined)
|
||||||
|
}
|
||||||
|
|
@ -178,16 +178,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
"session.execution.succeeded": () => clearCurrentRetry,
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
"session.execution.failed": () => clearCurrentRetry,
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
"session.execution.interrupted": () => clearCurrentRetry,
|
"session.execution.interrupted": () => clearCurrentRetry,
|
||||||
"session.instructions.updated": (event) =>
|
"session.instructions.updated": () => Effect.void,
|
||||||
adapter.appendMessage(
|
|
||||||
SessionMessage.System.make({
|
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
|
||||||
type: "system",
|
|
||||||
text: event.data.text,
|
|
||||||
metadata: event.metadata,
|
|
||||||
time: { created: event.created },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
"session.synthetic": (event) => {
|
"session.synthetic": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
SessionMessage.Synthetic.make({
|
SessionMessage.Synthetic.make({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as SessionInput from "./input"
|
export * as SessionPending from "./pending"
|
||||||
|
|
||||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
Compaction,
|
Compaction,
|
||||||
|
|
@ -11,14 +11,16 @@ import {
|
||||||
SyntheticData,
|
SyntheticData,
|
||||||
User,
|
User,
|
||||||
UserData,
|
UserData,
|
||||||
} from "@opencode-ai/schema/session-input"
|
} from "@opencode-ai/schema/session-pending"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import type { EventV2 } from "../event"
|
import type { EventV2 } from "../event"
|
||||||
|
import { EventTable } from "../event/sql"
|
||||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionInputTable, SessionMessageTable } from "./sql"
|
import { SessionMessageTable, SessionPendingTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
|
|
@ -28,25 +30,28 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||||
const encodeUser = Schema.encodeSync(UserData)
|
const encodeUser = Schema.encodeSync(UserData)
|
||||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
|
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
||||||
|
const admittedEventType = Event.versionedType(
|
||||||
|
SessionEvent.InputAdmitted.type,
|
||||||
|
SessionEvent.InputAdmitted.durable.version,
|
||||||
|
)
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
id: SessionMessage.ID,
|
"SessionPending.LifecycleConflict",
|
||||||
}) {}
|
{
|
||||||
|
id: SessionMessage.ID,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
|
||||||
const base = {
|
const base = {
|
||||||
admittedSeq: row.admitted_seq,
|
admittedSeq: row.admitted_seq,
|
||||||
id: SessionMessage.ID.make(row.id),
|
id: SessionMessage.ID.make(row.id),
|
||||||
sessionID: SessionSchema.ID.make(row.session_id),
|
sessionID: SessionSchema.ID.make(row.session_id),
|
||||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||||
}
|
}
|
||||||
if (row.type === "compaction")
|
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
|
||||||
return Compaction.make({
|
|
||||||
...base,
|
|
||||||
type: "compaction",
|
|
||||||
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
|
|
||||||
})
|
|
||||||
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
|
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
|
||||||
if (row.type === "user")
|
if (row.type === "user")
|
||||||
return User.make({
|
return User.make({
|
||||||
|
|
@ -54,7 +59,6 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||||
type: "user",
|
type: "user",
|
||||||
data: decodeUser(row.data),
|
data: decodeUser(row.data),
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
|
||||||
})
|
})
|
||||||
if (row.type === "synthetic")
|
if (row.type === "synthetic")
|
||||||
return Synthetic.make({
|
return Synthetic.make({
|
||||||
|
|
@ -62,31 +66,29 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
data: decodeSynthetic(row.data),
|
data: decodeSynthetic(row.data),
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
|
||||||
})
|
})
|
||||||
throw new LifecycleConflict({ id: base.id })
|
throw new LifecycleConflict({ id: base.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
||||||
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
|
const row = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.id, id))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
return row === undefined ? undefined : fromRow(row)
|
return row === undefined ? undefined : fromRow(row)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
|
export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
eq(SessionInputTable.type, "compaction"),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -95,7 +97,51 @@ export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(fun
|
||||||
return entry.type === "compaction" ? entry : undefined
|
return entry.type === "compaction" ? entry : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const admit = Effect.fn("SessionInput.admit")(function* (
|
/**
|
||||||
|
* Reconstruct the admitted record for a pending row that was already consumed
|
||||||
|
* by promotion. The projected `session_message` row proves promotion happened;
|
||||||
|
* the durable `session.input.admitted` event retains the exact admitted
|
||||||
|
* message, including delivery.
|
||||||
|
*/
|
||||||
|
const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
id: SessionMessage.ID,
|
||||||
|
) {
|
||||||
|
const message = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(eq(SessionMessageTable.id, id))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (message === undefined) return undefined
|
||||||
|
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
for (const row of rows) {
|
||||||
|
const decoded = decodeAdmittedEvent(row.data)
|
||||||
|
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
||||||
|
const base = {
|
||||||
|
admittedSeq: row.seq,
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
timeCreated: DateTime.makeUnsafe(row.created),
|
||||||
|
}
|
||||||
|
return decoded.value.input.type === "user"
|
||||||
|
? User.make({ ...base, ...decoded.value.input })
|
||||||
|
: Synthetic.make({ ...base, ...decoded.value.input })
|
||||||
|
}
|
||||||
|
// A projected message without an admitted event in this aggregate (for
|
||||||
|
// example fork-copied history) is not a retryable admission.
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
request: {
|
request: {
|
||||||
|
|
@ -109,6 +155,8 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
||||||
|
if (promoted !== undefined) return promoted
|
||||||
return yield* events
|
return yield* events
|
||||||
.publish(SessionEvent.InputAdmitted, {
|
.publish(SessionEvent.InputAdmitted, {
|
||||||
inputID: request.id,
|
inputID: request.id,
|
||||||
|
|
@ -141,7 +189,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
|
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||||
|
|
@ -153,7 +201,7 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
}
|
}
|
||||||
const pending = yield* pendingCompaction(db, input.sessionID)
|
const pending = yield* compaction(db, input.sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
return yield* events
|
return yield* events
|
||||||
.publish(SessionEvent.Compaction.Admitted, {
|
.publish(SessionEvent.Compaction.Admitted, {
|
||||||
|
|
@ -164,14 +212,14 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
Effect.flatMap((event) => {
|
Effect.flatMap((event) => {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
|
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
|
||||||
return pendingCompaction(db, input.sessionID).pipe(
|
return compaction(db, input.sessionID).pipe(
|
||||||
Effect.flatMap((stored) =>
|
Effect.flatMap((stored) =>
|
||||||
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
|
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
pendingCompaction(db, input.sessionID).pipe(
|
compaction(db, input.sessionID).pipe(
|
||||||
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
|
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -180,7 +228,7 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
request: {
|
request: {
|
||||||
readonly admittedSeq: number
|
readonly admittedSeq: number
|
||||||
|
|
@ -198,7 +246,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values({
|
.values({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
session_id: request.sessionID,
|
session_id: request.sessionID,
|
||||||
|
|
@ -209,13 +257,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||||
time_created: DateTime.toEpochMillis(request.timeCreated),
|
time_created: DateTime.toEpochMillis(request.timeCreated),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning({ id: SessionInputTable.id })
|
.returning({ id: SessionPendingTable.id })
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
|
export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompactionAdmitted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
readonly admittedSeq: number
|
readonly admittedSeq: number
|
||||||
|
|
@ -232,7 +280,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values({
|
.values({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
session_id: input.sessionID,
|
session_id: input.sessionID,
|
||||||
|
|
@ -249,84 +297,74 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti
|
||||||
const entry = fromRow(stored)
|
const entry = fromRow(stored)
|
||||||
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
|
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||||
}
|
}
|
||||||
const pending = yield* pendingCompaction(db, input.sessionID)
|
const pending = yield* compaction(db, input.sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
/**
|
||||||
|
* Consume one pending row at promotion. The row's content feeds the projected
|
||||||
|
* message insert inside the same event transaction; the deleted row is what
|
||||||
|
* makes the table pending-only.
|
||||||
|
*/
|
||||||
|
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
readonly id: SessionMessage.ID
|
readonly id: SessionMessage.ID
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly promotedSeq: number
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const updated = yield* db
|
const deleted = yield* db
|
||||||
.update(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.set({ promoted_seq: input.promotedSeq })
|
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionInputTable.id, input.id),
|
|
||||||
eq(SessionInputTable.session_id, input.sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const stored = updated ? fromRow(updated) : yield* find(db, input.id)
|
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
if (
|
const stored = fromRow(deleted)
|
||||||
!stored ||
|
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
stored.type === "compaction" ||
|
|
||||||
stored.sessionID !== input.sessionID ||
|
|
||||||
stored.promotedSeq !== input.promotedSeq
|
|
||||||
)
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
||||||
return stored
|
return stored
|
||||||
})
|
})
|
||||||
|
|
||||||
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
|
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
|
input: { readonly sessionID: SessionSchema.ID },
|
||||||
) {
|
) {
|
||||||
const updated = yield* db
|
const deleted = yield* db
|
||||||
.update(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.set({ promoted_seq: input.handledSeq })
|
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionInputTable.session_id, input.sessionID),
|
|
||||||
eq(SessionInputTable.type, "compaction"),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (updated) {
|
if (deleted) {
|
||||||
const stored = fromRow(updated)
|
const stored = fromRow(deleted)
|
||||||
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
|
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.session_id, sessionID))
|
||||||
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return rows.map(fromRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const has = Effect.fn("SessionPending.has")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
delivery: Delivery,
|
delivery: Delivery,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return false
|
if (yield* compaction(db, sessionID)) return false
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select({ id: SessionInputTable.id })
|
.select({ id: SessionPendingTable.id })
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery)))
|
||||||
and(
|
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, delivery),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -350,15 +388,15 @@ export const equivalent = (
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
|
||||||
) {
|
) {
|
||||||
return yield* inboxLocks.withLock(sessionID)(
|
return yield* inboxLocks.withLock(sessionID)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return 0
|
if (yield* compaction(db, sessionID)) return 0
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
rows,
|
rows,
|
||||||
(row) => {
|
(row) => {
|
||||||
|
|
@ -372,12 +410,8 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof LifecycleConflict
|
defect instanceof LifecycleConflict
|
||||||
? find(db, entry.id).pipe(
|
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
||||||
Effect.flatMap((stored) =>
|
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||||
stored?.type !== "compaction" && stored?.promotedSeq !== undefined
|
|
||||||
? Effect.void
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
|
|
@ -390,45 +424,33 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
export const promoteSteers = Effect.fn("SessionPending.promoteSteers")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return 0
|
if (yield* compaction(db, sessionID)) return 0
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, "steer"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return yield* publish(db, events, sessionID, rows)
|
return yield* publish(db, events, sessionID, rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
|
export const promoteNextQueued = Effect.fn("SessionPending.promoteNextQueued")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return false
|
if (yield* compaction(db, sessionID)) return false
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, "queue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as SessionProjector from "./projector"
|
export * as SessionProjector from "./projector"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
|
|
@ -11,24 +11,20 @@ import { SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { SessionInput } from "./input"
|
import { SessionPending } from "./pending"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { InstructionCheckpoint } from "./instruction-checkpoint"
|
import { InstructionState } from "./instruction-state"
|
||||||
import {
|
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||||
MessageTable,
|
|
||||||
PartTable,
|
|
||||||
InstructionCheckpointTable,
|
|
||||||
SessionInputTable,
|
|
||||||
SessionMessageTable,
|
|
||||||
SessionTable,
|
|
||||||
} from "./sql"
|
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
import { Slug } from "../util/slug"
|
import { Slug } from "../util/slug"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
type MessageEvent = Exclude<
|
||||||
|
CurrentDurableEvent,
|
||||||
|
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
||||||
|
>
|
||||||
|
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||||
|
|
@ -212,6 +208,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
fork_session_id: event.data.parentID,
|
fork_session_id: event.data.parentID,
|
||||||
fork_message_id: event.data.from,
|
fork_message_id: event.data.from,
|
||||||
|
fork_seq: event.data.parentSeq,
|
||||||
project_id: parent.project_id,
|
project_id: parent.project_id,
|
||||||
workspace_id: parent.workspace_id,
|
workspace_id: parent.workspace_id,
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
|
|
@ -236,23 +233,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||||
|
|
||||||
// The fork inherits the parent's transcript, so it inherits the context
|
|
||||||
// checkpoint that transcript was built against: copied message seqs keep
|
|
||||||
// folding at the same baseline horizon.
|
|
||||||
const checkpoint = yield* db
|
|
||||||
.select()
|
|
||||||
.from(InstructionCheckpointTable)
|
|
||||||
.where(eq(InstructionCheckpointTable.session_id, event.data.parentID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (checkpoint) {
|
|
||||||
yield* db
|
|
||||||
.insert(InstructionCheckpointTable)
|
|
||||||
.values({ ...checkpoint, session_id: event.data.sessionID })
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = -1
|
let cursor = -1
|
||||||
while (copiedSeq !== undefined) {
|
while (copiedSeq !== undefined) {
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
|
|
@ -293,25 +273,25 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const inputRows = yield* db
|
const pendingRows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionInputTable.session_id, event.data.parentID),
|
eq(SessionPendingTable.session_id, event.data.parentID),
|
||||||
inArray(
|
inArray(
|
||||||
SessionInputTable.id,
|
SessionPendingTable.id,
|
||||||
rows.map((row) => row.id),
|
rows.map((row) => row.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (inputRows.length > 0) {
|
if (pendingRows.length > 0) {
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values(
|
.values(
|
||||||
inputRows.flatMap((row) => {
|
pendingRows.flatMap((row) => {
|
||||||
const id = idMap.get(row.id)
|
const id = idMap.get(row.id)
|
||||||
return id && row.type !== "compaction"
|
return id && row.type !== "compaction"
|
||||||
? [
|
? [
|
||||||
|
|
@ -322,7 +302,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
data: row.data,
|
data: row.data,
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
admitted_seq: row.admitted_seq,
|
admitted_seq: row.admitted_seq,
|
||||||
promoted_seq: row.promoted_seq,
|
|
||||||
time_created: row.time_created,
|
time_created: row.time_created,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -335,7 +314,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
|
|
||||||
cursor = rows.at(-1)!.seq
|
cursor = rows.at(-1)!.seq
|
||||||
}
|
}
|
||||||
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
|
yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
|
||||||
|
yield* InstructionState.rebuild(db, event.data.sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
function run(db: DatabaseService, event: MessageEvent) {
|
function run(db: DatabaseService, event: MessageEvent) {
|
||||||
|
|
@ -523,7 +503,7 @@ const layer = Layer.effectDiscard(
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
|
yield* InstructionState.reset(db, event.data.sessionID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||||
|
|
@ -633,10 +613,9 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
const input = yield* SessionInput.projectPromoted(db, {
|
const input = yield* SessionPending.projectPromoted(db, {
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
promotedSeq: event.durable.seq,
|
|
||||||
})
|
})
|
||||||
yield* insertMessage(
|
yield* insertMessage(
|
||||||
db,
|
db,
|
||||||
|
|
@ -666,7 +645,7 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
yield* SessionInput.projectAdmitted(db, {
|
yield* SessionPending.projectAdmitted(db, {
|
||||||
admittedSeq: event.durable.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
|
@ -679,7 +658,7 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
yield* SessionInput.projectCompactionAdmitted(db, {
|
yield* SessionPending.projectCompactionAdmitted(db, {
|
||||||
admittedSeq: event.durable.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
|
@ -690,7 +669,9 @@ const layer = Layer.effectDiscard(
|
||||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||||
|
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
||||||
|
)
|
||||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||||
|
|
@ -724,13 +705,11 @@ const layer = Layer.effectDiscard(
|
||||||
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
|
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
if (event.data.reason === "manual")
|
if (event.data.reason === "manual")
|
||||||
yield* SessionInput.settleCompaction(db, {
|
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
handledSeq: event.durable.seq,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
|
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
|
||||||
|
|
@ -739,10 +718,7 @@ const layer = Layer.effectDiscard(
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
if (event.data.reason === "manual")
|
if (event.data.reason === "manual")
|
||||||
yield* SessionInput.settleCompaction(db, {
|
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
handledSeq: event.durable.seq,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||||
|
|
@ -786,11 +762,11 @@ const layer = Layer.effectDiscard(
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.delete(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionInputTable.session_id, event.data.sessionID),
|
eq(SessionPendingTable.session_id, event.data.sessionID),
|
||||||
or(gte(SessionInputTable.admitted_seq, boundary.seq), gte(SessionInputTable.promoted_seq, boundary.seq)),
|
gte(SessionPendingTable.admitted_seq, boundary.seq),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
@ -801,7 +777,7 @@ const layer = Layer.effectDiscard(
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
|
yield* InstructionState.reset(db, event.data.sessionID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
|
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ type Execution<E, Reason> = {
|
||||||
owner?: Fiber.Fiber<void>
|
owner?: Fiber.Fiber<void>
|
||||||
pendingWake: boolean
|
pendingWake: boolean
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
settling: boolean
|
|
||||||
interruptionReason?: Reason
|
interruptionReason?: Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +73,6 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
done: Deferred.makeUnsafe<void, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
pendingWake: false,
|
pendingWake: false,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
settling: false,
|
|
||||||
}
|
}
|
||||||
executions.set(key, execution)
|
executions.set(key, execution)
|
||||||
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
||||||
|
|
@ -86,7 +84,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
Effect.andThen(loop(key, execution, force)),
|
Effect.andThen(loop(key, execution, force)),
|
||||||
Effect.onExit((exit) =>
|
Effect.onExit((exit) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
execution.settling = true
|
execution.owner = undefined
|
||||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||||
),
|
),
|
||||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||||
|
|
@ -106,14 +104,14 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = (key: Key): Effect.Effect<void, E> =>
|
const run = (key: Key): Effect.Effect<void, E> =>
|
||||||
Effect.uninterruptibleMask((restore) => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution !== undefined) {
|
if (execution !== undefined) {
|
||||||
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
|
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
|
||||||
if (execution.stopping) return restore(Deferred.await(execution.done).pipe(Effect.andThen(run(key))))
|
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||||
return restore(Deferred.await(execution.done))
|
return Deferred.await(execution.done)
|
||||||
}
|
}
|
||||||
return restore(Deferred.await(start(key, true).done))
|
return Deferred.await(start(key, true).done)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wake = (key: Key) =>
|
const wake = (key: Key) =>
|
||||||
|
|
@ -129,7 +127,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||||
execution.stopping = true
|
execution.stopping = true
|
||||||
execution.pendingWake = false
|
execution.pendingWake = false
|
||||||
execution.interruptionReason = reason
|
execution.interruptionReason = reason
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
|
|
@ -30,11 +29,11 @@ import { InstructionEntry } from "../instruction-entry"
|
||||||
import { QuestionTool } from "../../tool/question"
|
import { QuestionTool } from "../../tool/question"
|
||||||
import { ToolRegistry } from "../../tool/registry"
|
import { ToolRegistry } from "../../tool/registry"
|
||||||
import { ToolOutputStore } from "../../tool-output-store"
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
import { InstructionCheckpoint } from "../instruction-checkpoint"
|
import { InstructionState } from "../instruction-state"
|
||||||
import { SessionCompaction } from "../compaction"
|
import { SessionCompaction } from "../compaction"
|
||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionHistory } from "../history"
|
import { SessionHistory } from "../history"
|
||||||
import { SessionInput } from "../input"
|
import { SessionPending } from "../pending"
|
||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
|
|
@ -48,11 +47,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { makeLocationNode } from "../../effect/app-node"
|
import { makeLocationNode } from "../../effect/app-node"
|
||||||
import { llmClient } from "../../effect/app-node-platform"
|
import { llmClient } from "../../effect/app-node-platform"
|
||||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
|
import { AgentNotFoundError, StepFailedError } from "../error"
|
||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
|
||||||
import { PluginHooks } from "../../plugin/hooks"
|
|
||||||
import { PluginSupervisor } from "../../plugin/supervisor"
|
import { PluginSupervisor } from "../../plugin/supervisor"
|
||||||
|
|
||||||
type StepTokens = {
|
type StepTokens = {
|
||||||
|
|
@ -80,53 +77,8 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one durable coding-agent Session until it settles.
|
* Runs one durable coding-agent Session until it settles. Each step reloads projected history,
|
||||||
*
|
* materializes tools, makes one model request, and settles local calls before continuation.
|
||||||
* Keep this as orchestration over smaller collaborators rather than rebuilding the legacy
|
|
||||||
* `SessionPrompt` monolith. Implement the unchecked items in small reviewed slices:
|
|
||||||
*
|
|
||||||
* - Session ownership and controls
|
|
||||||
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
|
||||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
|
||||||
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
|
|
||||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
|
||||||
* - [x] Honor optional agent step limits.
|
|
||||||
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
|
|
||||||
*
|
|
||||||
* - Runtime context assembly
|
|
||||||
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
|
||||||
*
|
|
||||||
* - One step
|
|
||||||
* - [x] Translate every projected V2 Session message variant into canonical
|
|
||||||
* `@opencode-ai/llm` messages.
|
|
||||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
|
||||||
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
|
|
||||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
|
||||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
|
||||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
|
||||||
*
|
|
||||||
* - Tool settlement and continuation
|
|
||||||
* - [x] Durably record each tool call before side effects begin.
|
|
||||||
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
|
|
||||||
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
|
|
||||||
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
|
|
||||||
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
|
|
||||||
* plugins, and cancellation settlement.
|
|
||||||
* - [x] Reload projected history and start the next explicit step after local tool results.
|
|
||||||
* - [x] Continue for durable user steering accepted during an active step.
|
|
||||||
* - [ ] Continue for compaction or another continuation condition when required.
|
|
||||||
*
|
|
||||||
* - Post-run maintenance
|
|
||||||
* - [ ] Settle final status and expose durable output events to replayable consumers.
|
|
||||||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
|
||||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
|
||||||
*
|
|
||||||
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
|
|
||||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
|
||||||
*
|
|
||||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
|
||||||
* step. Registry definitions are advertised, local tool calls are settled durably, and an
|
|
||||||
* explicit loop starts the next step after local settlement. Configured agent step limits bound the loop.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
|
|
@ -136,7 +88,6 @@ const layer = Layer.effect(
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const tools = yield* ToolRegistry.Service
|
const tools = yield* ToolRegistry.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
@ -161,6 +112,8 @@ const layer = Layer.effect(
|
||||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||||
return session
|
return session
|
||||||
})
|
})
|
||||||
|
const isCurrentLocation = (session: SessionSchema.Info) =>
|
||||||
|
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID
|
||||||
|
|
||||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
|
@ -173,7 +126,7 @@ const layer = Layer.effect(
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: message.id,
|
assistantMessageID: message.id,
|
||||||
callID: tool.id,
|
callID: tool.id,
|
||||||
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
|
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||||
executed: tool.executed === true,
|
executed: tool.executed === true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -203,50 +156,49 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionInput.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
recoverOverflow?: typeof compaction.compact,
|
||||||
assistantMessageID?: SessionMessage.ID,
|
assistantMessageID?: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
if (!isCurrentLocation(session)) return yield* Effect.interrupt
|
||||||
return yield* Effect.interrupt
|
|
||||||
yield* plugins.flush
|
yield* plugins.flush
|
||||||
const agent = yield* agents.select(session.agent)
|
const agent = yield* agents.select(session.agent)
|
||||||
const agentInfo = agent.info
|
const agentInfo = agent.info
|
||||||
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||||
// Establish what the model knows before admitting what the user said, so
|
// Establish what the model knows before admitting what the user said, so
|
||||||
// a blocked first step leaves pending inputs untouched.
|
// a blocked first step leaves pending inputs untouched.
|
||||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
const instructions = yield* loadInstructions(agent, session.id)
|
||||||
db,
|
yield* InstructionState.prepare(db, events, instructions, session.id)
|
||||||
events,
|
|
||||||
loadInstructions(agent, session.id),
|
|
||||||
session.id,
|
|
||||||
)
|
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
let promoted = 0
|
let promoted = 0
|
||||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, session.id)
|
||||||
if (promotion === "queue") {
|
if (promotion === "queue") {
|
||||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
promoted += Number(yield* SessionPending.promoteNextQueued(db, events, session.id))
|
||||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
promoted += yield* SessionPending.promoteSteers(db, events, session.id)
|
||||||
}
|
}
|
||||||
if (promoted > 0) currentStep = 1
|
if (promoted > 0) currentStep = 1
|
||||||
}
|
}
|
||||||
const resolved = yield* models.resolve(session)
|
const resolved = yield* models.resolve(session)
|
||||||
const model = resolved.model
|
const model = resolved.model
|
||||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = history.entries.map((entry) => entry.message)
|
||||||
|
const compactionInput = { sessionID: session.id, messages: context, model }
|
||||||
|
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||||
|
const compacted = yield* compaction.compact(compactionInput)
|
||||||
|
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||||
|
return yield* new StepFailedError({ error: compacted.error })
|
||||||
|
}
|
||||||
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||||
const toolMaterialization = isLastStep
|
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
|
||||||
? undefined
|
|
||||||
: yield* tools.materialize({ permissions: agentInfo.permissions, model })
|
|
||||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
model,
|
model,
|
||||||
providerOptions: { openai: { promptCacheKey } },
|
providerOptions: { openai: { promptCacheKey } },
|
||||||
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline]
|
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial]
|
||||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||||
.map(SystemPart.make),
|
.map(SystemPart.make),
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -256,39 +208,9 @@ const layer = Layer.effect(
|
||||||
tools: toolMaterialization?.definitions ?? [],
|
tools: toolMaterialization?.definitions ?? [],
|
||||||
toolChoice: isLastStep ? "none" : undefined,
|
toolChoice: isLastStep ? "none" : undefined,
|
||||||
})
|
})
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | StepFailedError>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | StepFailedError>> = []
|
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
|
||||||
const requestEvent: SessionHooks["request"] = {
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: agent.id,
|
|
||||||
model: resolved.ref,
|
|
||||||
system: [...request.system],
|
|
||||||
messages: [...request.messages],
|
|
||||||
tools: Object.fromEntries(
|
|
||||||
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
// Plugins may reshape the draft, but cannot advertise tools excluded earlier
|
|
||||||
// by permissions or registration state.
|
|
||||||
yield* hooks.trigger("session", "request", requestEvent)
|
|
||||||
const hookedRequest = LLM.updateRequest(request, {
|
|
||||||
system: requestEvent.system,
|
|
||||||
messages: requestEvent.messages,
|
|
||||||
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
|
|
||||||
const registered = availableTools.get(name)
|
|
||||||
if (!registered) return []
|
|
||||||
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
|
|
||||||
// Automatic compaction completed; rebuild the request from compacted history.
|
|
||||||
if (
|
|
||||||
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
|
|
||||||
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
|
|
||||||
)
|
|
||||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
|
||||||
const startSnapshot = yield* snapshots.capture()
|
const startSnapshot = yield* snapshots.capture()
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
@ -306,7 +228,7 @@ const layer = Layer.effect(
|
||||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
const providerStream = llm.stream(hookedRequest).pipe(
|
const providerStream = llm.stream(request).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (overflowFailure || publisher.hasProviderError()) return
|
if (overflowFailure || publisher.hasProviderError()) return
|
||||||
|
|
@ -327,21 +249,6 @@ const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A request hook hid this registered tool from the current request. Fail only
|
|
||||||
// this call durably and continue so the model can react, instead of executing
|
|
||||||
// a tool that was not advertised. Unregistered tools flow through settle, which
|
|
||||||
// durably fails them as unknown.
|
|
||||||
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
|
|
||||||
needsContinuation = true
|
|
||||||
yield* publish(
|
|
||||||
LLMEvent.toolError({
|
|
||||||
id: event.id,
|
|
||||||
name: event.name,
|
|
||||||
message: `Tool is not available for this request: ${event.name}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
needsContinuation = true
|
needsContinuation = true
|
||||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||||
ownedToolFibers.push(
|
ownedToolFibers.push(
|
||||||
|
|
@ -363,13 +270,6 @@ const layer = Layer.effect(
|
||||||
output: settlement.output,
|
output: settlement.output,
|
||||||
}),
|
}),
|
||||||
settlement.error,
|
settlement.error,
|
||||||
).pipe(
|
|
||||||
// Terminal cleanup must own failAssistant because the provider may still be streaming another tool input.
|
|
||||||
Effect.andThen(
|
|
||||||
settlement.error?.type === "permission.rejected"
|
|
||||||
? Effect.fail(new StepFailedError({ error: settlement.error }))
|
|
||||||
: Effect.void,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -423,7 +323,8 @@ const layer = Layer.effect(
|
||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||||
|
"completed"
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
|
|
@ -463,27 +364,17 @@ const layer = Layer.effect(
|
||||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||||
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
||||||
const userDeclined = settledCauses.some(isUserDeclined)
|
const userDeclined = settledCauses.some(isUserDeclined)
|
||||||
const permissionRejected = settledCauses
|
|
||||||
.map((cause) => Option.getOrUndefined(Cause.findErrorOption(cause)))
|
|
||||||
.find(
|
|
||||||
(error): error is StepFailedError =>
|
|
||||||
error instanceof StepFailedError && error.error.type === "permission.rejected",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
|
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||||
yield* FiberSet.clear(toolFibers)
|
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
||||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||||
yield* serialized(
|
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||||
publisher.failAssistant(permissionRejected?.error ?? { type: "aborted", message: "Step interrupted" }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||||
// implementation becomes a failed tool call the model can read, and the step still
|
// implementation becomes a failed tool call the model can read, and the step still
|
||||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||||
// could not be persisted) also fails the assistant and then fails the drain.
|
// could not be persisted) also fails the assistant and then fails the drain.
|
||||||
const settledFailure = settledCauses.find(
|
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||||
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
|
|
||||||
)
|
|
||||||
const infraError =
|
const infraError =
|
||||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||||
if (settledFailure !== undefined) {
|
if (settledFailure !== undefined) {
|
||||||
|
|
@ -526,22 +417,19 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const stepFailure = publisher.stepFailure()
|
const stepFailure = publisher.stepFailure()
|
||||||
const stepSettlement = publisher.stepSettlement()
|
const stepSettlement = publisher.stepSettlement()
|
||||||
const stepEndedCleanly =
|
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||||
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
|
|
||||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
|
||||||
if (stepFailure)
|
if (stepFailure)
|
||||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
||||||
|
|
||||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||||
if (userDeclined) return yield* Effect.interrupt
|
if (userDeclined) return yield* Effect.interrupt
|
||||||
if (permissionRejected) return yield* new UserInterruptedError()
|
|
||||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
||||||
return yield* Effect.failCause(settledFailure)
|
return yield* Effect.failCause(settledFailure)
|
||||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||||
return {
|
return {
|
||||||
_tag: "Completed",
|
_tag: "Completed",
|
||||||
needsContinuation: !providerFailed && needsContinuation,
|
needsContinuation,
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
} as const
|
} as const
|
||||||
}),
|
}),
|
||||||
|
|
@ -550,13 +438,13 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const runStep = Effect.fnUntraced(function* (
|
const runStep = Effect.fnUntraced(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionInput.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
) {
|
) {
|
||||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
||||||
// overflow, so the recovery hook is dropped after it fires.
|
// overflow, so the recovery hook is dropped after it fires.
|
||||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact
|
||||||
let currentPromotion = promotion
|
let currentPromotion = promotion
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
|
|
@ -595,8 +483,8 @@ const layer = Layer.effect(
|
||||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
|
const pending = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (!pending) return false
|
if (!pending) return
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -609,9 +497,9 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.exit)
|
).pipe(Effect.exit)
|
||||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
if (Exit.isSuccess(compacted)) return
|
||||||
if (Exit.isFailure(compacted)) {
|
if (Exit.isFailure(compacted)) {
|
||||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (unsettled)
|
if (unsettled)
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -621,15 +509,6 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
return yield* Effect.failCause(compacted.cause)
|
return yield* Effect.failCause(compacted.cause)
|
||||||
}
|
}
|
||||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
|
||||||
if (unsettled)
|
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
|
||||||
sessionID,
|
|
||||||
reason: "manual",
|
|
||||||
error: { type: "compaction.failed", message: "Compaction could not start" },
|
|
||||||
inputID: unsettled.id,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -640,11 +519,11 @@ const layer = Layer.effect(
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}) {
|
}) {
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||||
if (!input.force && !hasSteer && !hasQueue) return
|
if (!input.force && !hasSteer && !hasQueue) return
|
||||||
yield* failInterruptedTools(input.sessionID)
|
yield* failInterruptedTools(input.sessionID)
|
||||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
let promotion: SessionPending.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
let shouldRun = input.force || hasSteer || hasQueue
|
let shouldRun = input.force || hasSteer || hasQueue
|
||||||
while (shouldRun) {
|
while (shouldRun) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
|
|
@ -664,16 +543,16 @@ const layer = Layer.effect(
|
||||||
needsContinuation = result.needsContinuation
|
needsContinuation = result.needsContinuation
|
||||||
step = result.step + 1
|
step = result.step + 1
|
||||||
if (needsContinuation) {
|
if (needsContinuation) {
|
||||||
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
|
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
promotion = "steer"
|
promotion = "steer"
|
||||||
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
}
|
}
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||||
shouldRun = hasSteer || hasQueue
|
shouldRun = hasSteer || hasQueue
|
||||||
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
}
|
}
|
||||||
|
|
@ -691,7 +570,6 @@ export const node = makeLocationNode({
|
||||||
llmClient,
|
llmClient,
|
||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
ToolRegistry.node,
|
ToolRegistry.node,
|
||||||
PluginHooks.node,
|
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
|
|
@ -703,7 +581,6 @@ export const node = makeLocationNode({
|
||||||
InstructionEntry.node,
|
InstructionEntry.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Config.node,
|
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
PluginSupervisor.node,
|
PluginSupervisor.node,
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,12 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist one step without executing tools or starting a continuation step. */
|
/** Persist one step without executing tools or starting a continuation step. */
|
||||||
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
|
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
|
||||||
const tools = new Map<
|
const tools = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly name: string
|
readonly name: string
|
||||||
inputEnded: boolean
|
|
||||||
called: boolean
|
called: boolean
|
||||||
settled: boolean
|
settled: boolean
|
||||||
providerExecuted: boolean
|
providerExecuted: boolean
|
||||||
|
|
@ -140,7 +139,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
const flush = Effect.fnUntraced(function* () {
|
const flush = Effect.fnUntraced(function* () {
|
||||||
for (const id of chunks.keys()) yield* end(id)
|
for (const id of chunks.keys()) yield* end(id)
|
||||||
})
|
})
|
||||||
return { start, append, end, flush }
|
return { start, append, end, flush, has: (id: string) => chunks.has(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = fragments(
|
const text = fragments(
|
||||||
|
|
@ -180,7 +179,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
callID,
|
callID,
|
||||||
text: value,
|
text: value,
|
||||||
})
|
})
|
||||||
tool.inputEnded = true
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -196,7 +194,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
tools.set(event.id, {
|
tools.set(event.id, {
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
inputEnded: false,
|
|
||||||
called: false,
|
called: false,
|
||||||
settled: false,
|
settled: false,
|
||||||
providerExecuted: false,
|
providerExecuted: false,
|
||||||
|
|
@ -215,7 +212,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (tool.inputEnded) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
||||||
yield* toolInput.end(event.id)
|
yield* toolInput.end(event.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -330,7 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
if (!tool) return yield* Effect.die(new Error(`Tool input delta before start: ${event.id}`))
|
if (!tool) return yield* Effect.die(new Error(`Tool input delta before start: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (tool.inputEnded) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
|
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
|
||||||
yield* toolInput.append(event.id, event.text)
|
yield* toolInput.append(event.id, event.text)
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -347,7 +344,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
retryEvidence = true
|
retryEvidence = true
|
||||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
const tool = tools.get(event.id)!
|
const tool = tools.get(event.id)!
|
||||||
if (!tool.inputEnded) yield* endToolInput(event)
|
if (toolInput.has(event.id)) yield* endToolInput(event)
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||||
|
|
@ -364,7 +361,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
retryEvidence = true
|
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
|
|
@ -401,7 +397,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
retryEvidence = true
|
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"
|
||||||
import { directoryColumn, pathColumn } from "../database/path"
|
import { directoryColumn, pathColumn } from "../database/path"
|
||||||
import { ProjectTable } from "../project/sql"
|
import { ProjectTable } from "../project/sql"
|
||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import type { SessionInput } from "./input"
|
import type { SessionPending } from "./pending"
|
||||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { PermissionV1 } from "../v1/permission"
|
import { PermissionV1 } from "../v1/permission"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
|
|
@ -11,9 +11,9 @@ import type { SessionSchema } from "./schema"
|
||||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
import type { Instructions } from "../instructions/index"
|
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-input"
|
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
|
||||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||||
import type { Schema } from "effect"
|
import type { Schema } from "effect"
|
||||||
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const SessionTable = sqliteTable(
|
||||||
parent_id: text().$type<SessionSchema.ID>(),
|
parent_id: text().$type<SessionSchema.ID>(),
|
||||||
fork_session_id: text().$type<SessionSchema.ID>(),
|
fork_session_id: text().$type<SessionSchema.ID>(),
|
||||||
fork_message_id: text().$type<SessionMessage.ID>(),
|
fork_message_id: text().$type<SessionMessage.ID>(),
|
||||||
|
fork_seq: integer(),
|
||||||
slug: text().notNull(),
|
slug: text().notNull(),
|
||||||
directory: directoryColumn().notNull(),
|
directory: directoryColumn().notNull(),
|
||||||
path: pathColumn(),
|
path: pathColumn(),
|
||||||
|
|
@ -126,35 +127,28 @@ export const SessionMessageTable = sqliteTable(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SessionInputTable = sqliteTable(
|
export const SessionPendingTable = sqliteTable(
|
||||||
"session_input",
|
"session_pending",
|
||||||
{
|
{
|
||||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||||
session_id: text()
|
session_id: text()
|
||||||
.$type<SessionSchema.ID>()
|
.$type<SessionSchema.ID>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
type: text().$type<SessionInput.Info["type"]>().notNull(),
|
type: text().$type<SessionPending.Info["type"]>().notNull(),
|
||||||
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
|
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
|
||||||
delivery: text().$type<SessionInput.Delivery>(),
|
delivery: text().$type<SessionPending.Delivery>(),
|
||||||
admitted_seq: integer().notNull(),
|
admitted_seq: integer().notNull(),
|
||||||
promoted_seq: integer(),
|
|
||||||
time_created: integer()
|
time_created: integer()
|
||||||
.notNull()
|
.notNull()
|
||||||
.$default(() => Date.now()),
|
.$default(() => Date.now()),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("session_input_session_pending_delivery_seq_idx").on(
|
index("session_pending_session_delivery_seq_idx").on(table.session_id, table.delivery, table.admitted_seq),
|
||||||
table.session_id,
|
uniqueIndex("session_pending_session_compaction_idx")
|
||||||
table.promoted_seq,
|
|
||||||
table.delivery,
|
|
||||||
table.admitted_seq,
|
|
||||||
),
|
|
||||||
uniqueIndex("session_input_session_pending_compaction_idx")
|
|
||||||
.on(table.session_id)
|
.on(table.session_id)
|
||||||
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
|
.where(sql`${table.type} = 'compaction'`),
|
||||||
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
|
uniqueIndex("session_pending_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
|
||||||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -166,18 +160,25 @@ export const InstructionEntryTable = sqliteTable(
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
key: text().notNull(),
|
key: text().notNull(),
|
||||||
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
|
value: text({ mode: "json" }).$type<Schema.Json>(),
|
||||||
|
removed: integer({ mode: "boolean" }).notNull().default(false),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
|
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const InstructionCheckpointTable = sqliteTable("instruction_checkpoint", {
|
export const InstructionBlobTable = sqliteTable("instruction_blob", {
|
||||||
|
hash: text().$type<Instruction.Hash>().primaryKey(),
|
||||||
|
value: text({ mode: "json" }).$type<Schema.Json>(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InstructionStateTable = sqliteTable("instruction_state", {
|
||||||
session_id: text()
|
session_id: text()
|
||||||
.$type<SessionSchema.ID>()
|
.$type<SessionSchema.ID>()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
baseline: text().notNull(),
|
epoch_start: integer().notNull(),
|
||||||
snapshot: text({ mode: "json" }).notNull().$type<Instructions.Applied>(),
|
through_seq: integer().notNull(),
|
||||||
baseline_seq: integer().notNull(),
|
initial_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
|
||||||
|
current_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||||
|
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { QuestionV2 } from "../question"
|
import { QuestionV2 } from "../question"
|
||||||
|
|
@ -38,7 +39,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||||
}
|
}
|
||||||
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
|
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||||
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
|
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
|
||||||
if (cause instanceof ToolFailure)
|
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
|
||||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||||
if (cause instanceof StepFailedError) return cause.error
|
if (cause instanceof StepFailedError) return cause.error
|
||||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { PermissionV2 } from "../permission"
|
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Instructions } from "../instructions/index"
|
import { Instructions } from "../instructions/index"
|
||||||
|
|
||||||
|
|
@ -73,8 +72,6 @@ const layer = Layer.effect(
|
||||||
const agent = selection.info
|
const agent = selection.info
|
||||||
if (!agent) return Instructions.empty
|
if (!agent) return Instructions.empty
|
||||||
const permitted = SkillV2.available(yield* skills.list(), agent)
|
const permitted = SkillV2.available(yield* skills.list(), agent)
|
||||||
if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
|
|
||||||
return Instructions.empty
|
|
||||||
const available = permitted
|
const available = permitted
|
||||||
.flatMap((skill) =>
|
.flatMap((skill) =>
|
||||||
skill.description === undefined || skill.autoinvoke === false
|
skill.description === undefined || skill.autoinvoke === false
|
||||||
|
|
@ -82,13 +79,15 @@ const layer = Layer.effect(
|
||||||
: [{ id: skill.id, name: skill.name, description: skill.description }],
|
: [{ id: skill.id, name: skill.name, description: skill.description }],
|
||||||
)
|
)
|
||||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||||
return Instructions.make({
|
return Instructions.make<ReadonlyArray<Summary>>({
|
||||||
key: Instructions.Key.make("core/skill-guidance"),
|
key: Instructions.Key.make("core/skill-guidance"),
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(available),
|
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
|
||||||
baseline: render,
|
render: {
|
||||||
update,
|
initial: render,
|
||||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
changed: update,
|
||||||
|
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ Registrations are scoped:
|
||||||
|
|
||||||
- The latest active same-placement registration wins.
|
- The latest active same-placement registration wins.
|
||||||
- Closing any registration removes only that registration and reveals the next active one.
|
- Closing any registration removes only that registration and reveals the next active one.
|
||||||
- An invocation captures the effective tool once settlement starts.
|
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
|
||||||
|
|
||||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,23 +36,19 @@ type CollectedFiles = {
|
||||||
readonly files: Array<typeof ExecuteFile.Type>
|
readonly files: Array<typeof ExecuteFile.Type>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Registration {
|
interface Registration {
|
||||||
readonly identity: object
|
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly group?: string
|
readonly group?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const create = (options: {
|
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||||
readonly registrations: ReadonlyMap<string, Registration>
|
|
||||||
readonly current: (name: string) => Registration | undefined
|
|
||||||
}) => {
|
|
||||||
const runtime = (
|
const runtime = (
|
||||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||||
hooks?: CodeMode.ToolCallHooks,
|
hooks?: CodeMode.ToolCallHooks,
|
||||||
) => {
|
) => {
|
||||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
||||||
for (const [name, registration] of options.registrations) {
|
for (const [name, registration] of registrations) {
|
||||||
const child = definition(name, registration.tool)
|
const child = definition(name, registration.tool)
|
||||||
const value = Tool.make({
|
const value = Tool.make({
|
||||||
description: child.description,
|
description: child.description,
|
||||||
|
|
@ -115,11 +111,8 @@ export const create = (options: {
|
||||||
(name, registration, input) =>
|
(name, registration, input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||||
const current = options.current(name)
|
|
||||||
if (!current || current.identity !== registration.identity)
|
|
||||||
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
|
|
||||||
const output = yield* settle(
|
const output = yield* settle(
|
||||||
current.tool,
|
registration.tool,
|
||||||
{ type: "tool-call", id: context.toolCallID, name, input },
|
{ type: "tool-call", id: context.toolCallID, name, input },
|
||||||
{
|
{
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
|
|
@ -176,9 +169,12 @@ function formatResult(result: CodeMode.Result) {
|
||||||
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
|
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim()
|
.trim()
|
||||||
if (!result.logs || result.logs.length === 0) return output
|
const warnings =
|
||||||
const logs = `Logs:\n${result.logs.join("\n")}`
|
result.ok && result.warnings && result.warnings.length > 0
|
||||||
return output === "" ? logs : `${output}\n\n${logs}`
|
? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
|
||||||
|
: undefined
|
||||||
|
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
|
||||||
|
return [output, warnings, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: CodeMode.DataValue) {
|
function formatValue(value: CodeMode.DataValue) {
|
||||||
|
|
|
||||||
|
|
@ -194,19 +194,6 @@ export const Plugin = {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
yield* ctx.session.hook("request", (event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const usePatch =
|
|
||||||
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
|
|
||||||
if (usePatch) {
|
|
||||||
delete event.tools.edit
|
|
||||||
delete event.tools.write
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete event.tools.patch
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Usage notes:
|
||||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Output = Schema.Struct({
|
export const Output = Schema.Struct({
|
||||||
|
|
@ -86,21 +86,10 @@ export const Plugin = {
|
||||||
kind: "question",
|
kind: "question",
|
||||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
},
|
},
|
||||||
mode: "form",
|
fields: [
|
||||||
fields: input.questions.map(
|
toField(input.questions[0], 0),
|
||||||
(question, index): Form.Field => ({
|
...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
|
||||||
key: `q${index}`,
|
],
|
||||||
title: question.header,
|
|
||||||
description: question.question,
|
|
||||||
type: question.multiple === true ? "multiselect" : "string",
|
|
||||||
options: question.options.map((option) => ({
|
|
||||||
value: option.label,
|
|
||||||
label: option.label,
|
|
||||||
description: option.description,
|
|
||||||
})),
|
|
||||||
custom: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
),
|
),
|
||||||
|
|
@ -122,3 +111,18 @@ export const Plugin = {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toField(question: QuestionV2.Prompt, index: number): Form.Field {
|
||||||
|
return {
|
||||||
|
key: `q${index}`,
|
||||||
|
title: question.header,
|
||||||
|
description: question.question,
|
||||||
|
type: question.multiple === true ? "multiselect" : "string",
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
value: option.label,
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
custom: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export const Plugin = {
|
||||||
const resolved = yield* fs.resolve(target.canonical)
|
const resolved = yield* fs.resolve(target.canonical)
|
||||||
const root = yield* fs.resolve(location.directory)
|
const root = yield* fs.resolve(location.directory)
|
||||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||||
const discovered = yield* fs.up({
|
const discovered = yield* fs.up({
|
||||||
targets: [FILENAME],
|
targets: [FILENAME],
|
||||||
start: type === "directory" ? resolved : dirname(resolved),
|
start: type === "directory" ? resolved : dirname(resolved),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export type ExecuteInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
|
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||||
readonly register: (
|
readonly register: (
|
||||||
tools: Readonly<Record<string, AnyTool>>,
|
tools: Readonly<Record<string, AnyTool>>,
|
||||||
|
|
@ -33,11 +33,6 @@ export interface Interface {
|
||||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaterializeInput {
|
|
||||||
readonly model: { readonly id: string; readonly provider: string }
|
|
||||||
readonly permissions?: PermissionV2.Ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Materialization {
|
export interface Materialization {
|
||||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
||||||
|
|
@ -58,7 +53,6 @@ const registryLayer = Layer.effect(
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
const toolHooks = yield* ToolHooks.Service
|
||||||
type Registration = {
|
type Registration = {
|
||||||
readonly identity: object
|
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly group?: string
|
readonly group?: string
|
||||||
|
|
@ -134,18 +128,6 @@ const registryLayer = Layer.effect(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
|
|
||||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
|
||||||
if (!registration || registration.identity !== advertised) {
|
|
||||||
const message = `Stale tool call: ${input.call.name}`
|
|
||||||
return {
|
|
||||||
result: { type: "error" as const, value: message },
|
|
||||||
error: { type: "tool.stale" as const, message },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return yield* settleTool(input, registration.tool)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||||
const entries = registrationEntries(tools, options?.group)
|
const entries = registrationEntries(tools, options?.group)
|
||||||
|
|
@ -164,7 +146,6 @@ const registryLayer = Layer.effect(
|
||||||
{
|
{
|
||||||
token,
|
token,
|
||||||
registration: {
|
registration: {
|
||||||
identity: {},
|
|
||||||
tool: entry.tool,
|
tool: entry.tool,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
group: entry.group,
|
group: entry.group,
|
||||||
|
|
@ -185,28 +166,20 @@ const registryLayer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
|
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
|
||||||
const registrations = new Map<string, Registration>()
|
const direct = new Map<string, Registration>()
|
||||||
|
const deferred = new Map<string, Registration>()
|
||||||
|
const rules = permissions ?? []
|
||||||
for (const [name, entries] of local) {
|
for (const [name, entries] of local) {
|
||||||
const registration = entries.at(-1)?.registration
|
const registration = entries.at(-1)?.registration
|
||||||
if (registration) registrations.set(name, registration)
|
if (!registration) continue
|
||||||
|
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
|
||||||
|
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||||
|
if (registration.deferred) deferred.set(name, registration)
|
||||||
|
else direct.set(name, registration)
|
||||||
}
|
}
|
||||||
for (const [name, registration] of registrations) {
|
|
||||||
if (
|
|
||||||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
|
|
||||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
|
||||||
)
|
|
||||||
registrations.delete(name)
|
|
||||||
}
|
|
||||||
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
|
||||||
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
|
|
||||||
const execute =
|
const execute =
|
||||||
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
|
||||||
? ExecuteTool.create({
|
|
||||||
registrations: deferred,
|
|
||||||
current: (name) => local.get(name)?.at(-1)?.registration,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
return {
|
return {
|
||||||
definitions: [
|
definitions: [
|
||||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||||
|
|
@ -215,7 +188,7 @@ const registryLayer = Layer.effect(
|
||||||
settle: (input) => {
|
settle: (input) => {
|
||||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||||
const registration = direct.get(input.call.name)
|
const registration = direct.get(input.call.name)
|
||||||
if (registration) return settleWith(input, registration.identity)
|
if (registration) return settleTool(input, registration.tool)
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||||
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
||||||
|
|
|
||||||
|
|
@ -192,32 +192,5 @@ export const Plugin = {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
yield* ctx.session.hook("request", (event) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const tool = event.tools[name]
|
|
||||||
if (!tool) return
|
|
||||||
const selected = yield* agents.resolve(event.agent)
|
|
||||||
if (!selected) return
|
|
||||||
const available = (yield* agents.list())
|
|
||||||
.filter(
|
|
||||||
(agent) =>
|
|
||||||
agent.mode !== "primary" &&
|
|
||||||
!agent.hidden &&
|
|
||||||
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
|
|
||||||
)
|
|
||||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
|
||||||
if (available.length === 0) return
|
|
||||||
tool.description = [
|
|
||||||
tool.description,
|
|
||||||
"",
|
|
||||||
"Available subagents:",
|
|
||||||
...available.map(
|
|
||||||
(agent) =>
|
|
||||||
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
|
|
||||||
),
|
|
||||||
].join("\n")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,43 @@ describe("Config", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("does not watch ecosystem config roots", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.all([
|
||||||
|
fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
|
||||||
|
fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const targets: Watcher.WatchInput[] = []
|
||||||
|
const watcher = Layer.succeed(
|
||||||
|
Watcher.Service,
|
||||||
|
Watcher.Service.of({
|
||||||
|
subscribe: (input) => {
|
||||||
|
targets.push(input)
|
||||||
|
return Stream.never
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
yield* config.entries()
|
||||||
|
|
||||||
|
expect(targets).toEqual([
|
||||||
|
{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) },
|
||||||
|
])
|
||||||
|
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
@ -915,8 +952,11 @@ describe("Config", () => {
|
||||||
"global",
|
"global",
|
||||||
AbsolutePath.make(global),
|
AbsolutePath.make(global),
|
||||||
"root",
|
"root",
|
||||||
|
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||||
"parent",
|
"parent",
|
||||||
|
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
|
||||||
"directory",
|
"directory",
|
||||||
|
AbsolutePath.make(path.join(directory, "opencode.json")),
|
||||||
"root-dot",
|
"root-dot",
|
||||||
AbsolutePath.make(path.join(root, ".opencode")),
|
AbsolutePath.make(path.join(root, ".opencode")),
|
||||||
"directory-dot",
|
"directory-dot",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,32 @@ describe("PluginSupervisor config", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("disables configured plugins by exported ID", () => {
|
||||||
|
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||||
|
return withLocation(
|
||||||
|
{ plugins: [plugin, "-config-promise-plugin"] },
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ready()
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
|
||||||
|
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.live("does not disable configured plugins by package target", () => {
|
||||||
|
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||||
|
return withLocation(
|
||||||
|
{ plugins: [plugin, `-${plugin}`] },
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ready()
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.live("loads configured Effect plugins with options", () =>
|
it.live("loads configured Effect plugins with options", () =>
|
||||||
withLocation(
|
withLocation(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||||
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
import eventSourcedSessionPendingMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
||||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
import simplifySessionPendingMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||||
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
||||||
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
||||||
import genericSessionInputMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
|
import genericSessionPendingMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
|
||||||
|
import sessionPendingTableMigration from "@opencode-ai/core/database/migration/20260709190621_session_pending_table"
|
||||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||||
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
|
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
|
||||||
|
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
|
@ -352,30 +354,32 @@ describe("DatabaseMigration", () => {
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||||
).toEqual({ name: "session_input" })
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pending'`),
|
||||||
|
).toEqual({ name: "session_pending" })
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
||||||
).toEqual({ name: "instruction_checkpoint" })
|
|
||||||
expect(
|
|
||||||
yield* db.get(
|
|
||||||
sql`SELECT name FROM pragma_table_info('instruction_checkpoint') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
|
|
||||||
),
|
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
yield* db.all(
|
||||||
|
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('instruction_blob', 'instruction_state') ORDER BY name`,
|
||||||
|
),
|
||||||
|
).toEqual([{ name: "instruction_blob" }, { name: "instruction_state" }])
|
||||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||||
expect(
|
expect(
|
||||||
yield* db.all(
|
yield* db.all(
|
||||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_pending_session_delivery_seq_idx', 'session_pending_session_compaction_idx', 'session_pending_session_admitted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ name: "event_aggregate_seq_idx" },
|
{ name: "event_aggregate_seq_idx" },
|
||||||
{ name: "event_aggregate_type_seq_idx" },
|
{ name: "event_aggregate_type_seq_idx" },
|
||||||
{ name: "session_input_session_admitted_seq_idx" },
|
|
||||||
{ name: "session_input_session_pending_compaction_idx" },
|
|
||||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
|
||||||
{ name: "session_input_session_promoted_seq_idx" },
|
|
||||||
{ name: "session_message_session_seq_idx" },
|
{ name: "session_message_session_seq_idx" },
|
||||||
{ name: "session_message_session_time_created_id_idx" },
|
{ name: "session_message_session_time_created_id_idx" },
|
||||||
{ name: "session_message_session_type_seq_idx" },
|
{ name: "session_message_session_type_seq_idx" },
|
||||||
|
{ name: "session_pending_session_admitted_seq_idx" },
|
||||||
|
{ name: "session_pending_session_compaction_idx" },
|
||||||
|
{ name: "session_pending_session_delivery_seq_idx" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -504,6 +508,91 @@ describe("DatabaseMigration", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("deletes pre-beta instruction events and projected System messages", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session VALUES ('ses_test', NULL)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO event VALUES ('evt_instruction', 'ses_test', 0, 'session.instructions.updated.1', '{"sessionID":"ses_test","text":"changed"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO event VALUES ('evt_other', 'ses_test', 1, 'session.synthetic.1', '{"sessionID":"ses_test","text":"keep"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO session_message VALUES ('msg_instruction', 'system'), ('msg_other', 'system'), ('msg_user', 'user')`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`INSERT INTO instruction_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
|
||||||
|
|
||||||
|
expect(yield* db.all(sql`SELECT id, type FROM event`)).toEqual([
|
||||||
|
{ id: "evt_other", type: "session.synthetic.1" },
|
||||||
|
])
|
||||||
|
expect(yield* db.all(sql`SELECT id, type FROM session_message ORDER BY id`)).toEqual([
|
||||||
|
{ id: "msg_user", type: "user" },
|
||||||
|
])
|
||||||
|
expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
|
||||||
|
session_id: "ses_test",
|
||||||
|
key: "plan",
|
||||||
|
value: '"ready"',
|
||||||
|
removed: 0,
|
||||||
|
time_created: 1,
|
||||||
|
time_updated: 2,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
||||||
|
).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("records the authoritative parent sequence on existing forks", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`INSERT INTO session VALUES ('ses_child', 'ses_parent')`)
|
||||||
|
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_child', 8)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO event VALUES ('evt_fork', 'ses_child', 0, 'session.forked.1', '{"sessionID":"ses_child","parentID":"ses_parent"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO event VALUES ('evt_instruction', 'ses_child', 5, 'session.instructions.updated.1', '{"sessionID":"ses_child","text":"changed"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`INSERT INTO event VALUES ('evt_input', 'ses_child', 6, 'session.input.admitted.1', '{}')`)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
|
||||||
|
|
||||||
|
expect(yield* db.get(sql`SELECT fork_seq FROM session`)).toEqual({ fork_seq: 4 })
|
||||||
|
expect(yield* db.get(sql`SELECT type, data FROM event WHERE seq = 0`)).toEqual({
|
||||||
|
type: "session.forked.2",
|
||||||
|
data: '{"sessionID":"ses_child","parentID":"ses_parent","parentSeq":4}',
|
||||||
|
})
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM event WHERE id = 'evt_instruction'`)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps legacy credential fields nullable", async () => {
|
test("keeps legacy credential fields nullable", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -568,7 +657,7 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
|
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration])
|
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionPendingMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
|
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
|
||||||
{ id: "session", workspace_id: null },
|
{ id: "session", workspace_id: null },
|
||||||
|
|
@ -631,18 +720,23 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
||||||
)
|
)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
|
sql`INSERT INTO session_pending (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
|
||||||
)
|
)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
|
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
|
||||||
)
|
)
|
||||||
|
yield* db.run(sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY)`)
|
||||||
|
// The partial compaction index embeds the qualified table name, so it
|
||||||
|
// must drop before the historical rename dance and recreate after.
|
||||||
|
yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
|
||||||
|
yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
|
||||||
|
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
|
||||||
|
yield* db.run(sql`DROP TABLE session_context_epoch`)
|
||||||
|
yield* db.run(sql`ALTER TABLE session_input RENAME TO session_pending`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
sql`CREATE UNIQUE INDEX session_pending_session_compaction_idx ON session_pending (session_id) WHERE "session_pending"."type" = 'compaction'`,
|
||||||
)
|
)
|
||||||
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
|
|
||||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`)
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
|
||||||
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
|
|
||||||
|
|
||||||
const database = Layer.succeed(Database.Service, { db })
|
const database = Layer.succeed(Database.Service, { db })
|
||||||
yield* EventV2.Service.use((service) =>
|
yield* EventV2.Service.use((service) =>
|
||||||
|
|
@ -672,9 +766,9 @@ describe("DatabaseMigration", () => {
|
||||||
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
|
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
|
||||||
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
|
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
|
||||||
(SELECT COUNT(*) FROM workspace) AS workspaces,
|
(SELECT COUNT(*) FROM workspace) AS workspaces,
|
||||||
(SELECT COUNT(*) FROM session_input) AS sessionInputs,
|
(SELECT COUNT(*) FROM session_pending) AS sessionInputs,
|
||||||
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
|
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
|
||||||
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
|
(SELECT COUNT(*) FROM instruction_state) AS instructionStates,
|
||||||
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
|
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
|
||||||
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
|
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
|
||||||
`),
|
`),
|
||||||
|
|
@ -686,7 +780,7 @@ describe("DatabaseMigration", () => {
|
||||||
workspaces: 0,
|
workspaces: 0,
|
||||||
sessionInputs: 0,
|
sessionInputs: 0,
|
||||||
sessionMessages: 0,
|
sessionMessages: 0,
|
||||||
instructionCheckpoints: 0,
|
instructionStates: 0,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
eventType: "session.updated.1",
|
eventType: "session.updated.1",
|
||||||
})
|
})
|
||||||
|
|
@ -756,7 +850,7 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
|
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [genericSessionInputMigration])
|
yield* DatabaseMigration.applyOnly(db, [genericSessionPendingMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
|
expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
|
||||||
{ id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
|
{ id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
|
||||||
|
|
@ -775,6 +869,47 @@ describe("DatabaseMigration", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("replaces the durable inbox with the empty session_pending table", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE, type text NOT NULL, data text NOT NULL, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||||
|
)
|
||||||
|
// Interim v2 builds shipped differing index sets on real databases;
|
||||||
|
// dropping the table removes whatever variant exists.
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE INDEX session_input_session_pending_type_delivery_seq_idx ON session_input (session_id, promoted_seq, type, delivery, admitted_seq)`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, promoted_seq, time_created) VALUES ('pending', 'session', 'user', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [sessionPendingTableMigration])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(yield* db.all(sql`SELECT id FROM session_pending`)).toEqual([])
|
||||||
|
expect(
|
||||||
|
(yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_pending)`)).map((column) => column.name),
|
||||||
|
).toEqual(["id", "session_id", "type", "data", "delivery", "admitted_seq", "time_created"])
|
||||||
|
expect(
|
||||||
|
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_pending)`))
|
||||||
|
.filter((index) => index.name.startsWith("session_"))
|
||||||
|
.map((index) => ({ name: index.name, unique: index.unique }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "session_pending_session_admitted_seq_idx", unique: 1 },
|
||||||
|
{ name: "session_pending_session_compaction_idx", unique: 1 },
|
||||||
|
{ name: "session_pending_session_delivery_seq_idx", unique: 0 },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,17 @@ const SyncSent = EventV2.durable({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const VersionedMessageV1 = EventV2.durable({
|
||||||
|
type: "test.versioned",
|
||||||
|
durable: { version: 1, aggregate: "id" },
|
||||||
|
schema: { id: Schema.String },
|
||||||
|
})
|
||||||
|
const VersionedMessageV2 = EventV2.durable({
|
||||||
|
type: "test.versioned",
|
||||||
|
durable: { version: 2, aggregate: "id" },
|
||||||
|
schema: { id: Schema.String },
|
||||||
|
})
|
||||||
|
|
||||||
const GlobalMessage = EventV2.ephemeral({
|
const GlobalMessage = EventV2.ephemeral({
|
||||||
type: "test.global",
|
type: "test.global",
|
||||||
schema: {
|
schema: {
|
||||||
|
|
@ -722,16 +733,35 @@ describe("EventV2", () => {
|
||||||
yield* events.replay({
|
yield* events.replay({
|
||||||
id: EventV2.ID.create(),
|
id: EventV2.ID.create(),
|
||||||
created: DateTime.makeUnsafe(0),
|
created: DateTime.makeUnsafe(0),
|
||||||
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 1),
|
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 2),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
data: { sessionID: aggregateID, text: "context" },
|
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
|
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("dispatches durable projectors by exact event version", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const received = new Array<typeof VersionedMessageV2.Type>()
|
||||||
|
yield* events.project(VersionedMessageV2, (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
received.push(event)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* events.publish(VersionedMessageV1, { id: aggregateID })
|
||||||
|
yield* events.publish(VersionedMessageV2, { id: aggregateID })
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
expect(received[0]?.durable.version).toBe(EventV2.Version.make(2))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("replay defects on unknown event type", () =>
|
it.effect("replay defects on unknown event type", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ const input = {
|
||||||
id: formID,
|
id: formID,
|
||||||
sessionID: SessionSchema.ID.make("ses_test"),
|
sessionID: SessionSchema.ID.make("ses_test"),
|
||||||
title: "Test form",
|
title: "Test form",
|
||||||
mode: "form",
|
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
} satisfies Form.CreateInput
|
} satisfies Form.CreateInput
|
||||||
|
|
||||||
|
|
@ -47,7 +46,6 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "MCP input",
|
title: "MCP input",
|
||||||
mode: "form",
|
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
})
|
})
|
||||||
expect(created.sessionID).toBe("global")
|
expect(created.sessionID).toBe("global")
|
||||||
|
|
@ -59,6 +57,14 @@ describe("Form", () => {
|
||||||
|
|
||||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||||
|
|
||||||
|
const externalOnly = yield* service.create({
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External setup",
|
||||||
|
fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
|
||||||
|
})
|
||||||
|
yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
|
||||||
|
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -68,15 +74,18 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Conditional form",
|
title: "Conditional form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "confirm", type: "boolean", required: true },
|
{ key: "confirm", type: "boolean", required: true },
|
||||||
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
|
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip)
|
const inactive = yield* service
|
||||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
|
.reply({ id: created.id, answer: { confirm: true, reason: "x" } })
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
expect(inactive).toEqual(
|
||||||
|
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }),
|
||||||
|
)
|
||||||
|
|
||||||
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
|
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
|
||||||
expect(missing).toEqual(
|
expect(missing).toEqual(
|
||||||
|
|
@ -101,7 +110,6 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Multiselect form",
|
title: "Multiselect form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
||||||
|
|
@ -124,7 +132,6 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Dependent form",
|
title: "Dependent form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", type: "boolean" },
|
{ key: "b", type: "boolean" },
|
||||||
|
|
@ -142,7 +149,9 @@ describe("Form", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
|
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
|
||||||
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
|
expect(missingX).toEqual(
|
||||||
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }),
|
||||||
|
)
|
||||||
|
|
||||||
const inactiveX = yield* service
|
const inactiveX = yield* service
|
||||||
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
||||||
|
|
@ -150,7 +159,9 @@ describe("Form", () => {
|
||||||
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
|
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
|
||||||
|
|
||||||
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
|
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
|
||||||
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
|
expect(missingZ).toEqual(
|
||||||
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }),
|
||||||
|
)
|
||||||
|
|
||||||
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
|
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
|
||||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
|
||||||
|
|
@ -167,7 +178,6 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Selection form",
|
title: "Selection form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
||||||
|
|
@ -186,7 +196,9 @@ describe("Form", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
|
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
|
||||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
|
expect(inactive).toEqual(
|
||||||
|
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }),
|
||||||
|
)
|
||||||
|
|
||||||
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
||||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
|
||||||
|
|
@ -199,7 +211,6 @@ describe("Form", () => {
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Cascading form",
|
title: "Cascading form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
||||||
|
|
@ -220,14 +231,14 @@ describe("Form", () => {
|
||||||
it.effect("rejects invalid when definitions at creation", () =>
|
it.effect("rejects invalid when definitions at creation", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
const flipCreate = (fields: Form.CreateInput["fields"]) =>
|
||||||
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
|
service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
|
||||||
{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] },
|
).toEqual(
|
||||||
]),
|
new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }),
|
||||||
).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
|
|
@ -236,14 +247,19 @@ describe("Form", () => {
|
||||||
]),
|
]),
|
||||||
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* flipCreate([
|
||||||
|
{ key: "a", type: "external", url: "https://example.com" },
|
||||||
|
{ key: "a", type: "string" },
|
||||||
|
]),
|
||||||
|
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
||||||
]),
|
]),
|
||||||
).toEqual(
|
).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
|
||||||
new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
|
|
@ -258,6 +274,40 @@ describe("Form", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("requires external field acknowledgements", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Form.Service
|
||||||
|
const created = yield* service.create({
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External setup",
|
||||||
|
fields: [
|
||||||
|
{ key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
|
||||||
|
{ key: "name", type: "string", required: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidAnswers: ReadonlyArray<Form.Answer> = [
|
||||||
|
{ name: "Ava" },
|
||||||
|
{ authorization: false, name: "Ava" },
|
||||||
|
{ authorization: "yes", name: "Ava" },
|
||||||
|
]
|
||||||
|
for (const answer of invalidAnswers) {
|
||||||
|
expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
|
||||||
|
new Form.InvalidAnswerError({
|
||||||
|
id: created.id,
|
||||||
|
message: "External form field must be acknowledged: authorization",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
|
||||||
|
expect(yield* service.state(created.id)).toEqual({
|
||||||
|
status: "answered",
|
||||||
|
answer: { authorization: true, name: "Ava" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("cleans up created forms when event publication fails", () =>
|
it.effect("cleans up created forms when event publication fails", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { Global } from "@opencode-ai/core/global"
|
||||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Instructions } from "@opencode-ai/core/instructions"
|
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(Layer.empty)
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ describe("InstructionDiscovery", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const initialized = yield* Instructions.initialize(yield* load)
|
const initialized = yield* readInitial(yield* load)
|
||||||
expect(initialized.text).toBe(
|
expect(initialized.text).toBe(
|
||||||
[
|
[
|
||||||
`Instructions from: ${globalFile}\nglobal`,
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
|
|
@ -80,29 +80,24 @@ describe("InstructionDiscovery", () => {
|
||||||
expect(initialized.text).not.toContain("outside")
|
expect(initialized.text).not.toContain("outside")
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||||
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toMatchObject({
|
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
|
||||||
_tag: "Updated",
|
`Instructions from: ${packageFile}\nchanged`,
|
||||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.rm(packageFile))
|
yield* Effect.promise(() => fs.rm(packageFile))
|
||||||
const partial = yield* Instructions.reconcile(yield* load, initialized.applied)
|
const partial = yield* readUpdate(yield* load, initialized)
|
||||||
expect(partial).toEqual({
|
expect(partial.text).toBe(
|
||||||
_tag: "Updated",
|
[
|
||||||
text: [
|
|
||||||
"These instructions replace all previously loaded ambient instructions.",
|
"These instructions replace all previously loaded ambient instructions.",
|
||||||
`Instructions from: ${globalFile}\nglobal`,
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
`Instructions from: ${projectFile}\nproject`,
|
`Instructions from: ${projectFile}\nproject`,
|
||||||
].join("\n\n"),
|
].join("\n\n"),
|
||||||
applied: expect.any(Object),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||||
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toEqual({
|
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
|
||||||
_tag: "Updated",
|
"Previously loaded instructions no longer apply.",
|
||||||
text: "Previously loaded instructions no longer apply.",
|
)
|
||||||
applied: {},
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -130,7 +125,7 @@ describe("InstructionDiscovery", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect((yield* Instructions.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -161,13 +156,9 @@ describe("InstructionDiscovery", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* Instructions.reconcile(context, {
|
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
|
||||||
"core/instructions": {
|
.changed,
|
||||||
value: [{ path: "/repo/AGENTS.md", content: "old" }],
|
).toBe(false)
|
||||||
removed: "Previously loaded instructions no longer apply.",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({ _tag: "Unchanged" })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -201,13 +192,8 @@ describe("InstructionDiscovery", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* Instructions.reconcile(context, {
|
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
|
||||||
"core/instructions": {
|
).toBe(false)
|
||||||
value: [{ path: file, content: "old" }],
|
|
||||||
removed: "Previously loaded instructions no longer apply.",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({ _tag: "Unchanged" })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import { Location } from "@opencode-ai/core/location"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Instructions } from "@opencode-ai/core/instructions"
|
|
||||||
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
||||||
import { location } from "../fixture/location"
|
import { location } from "../fixture/location"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { readInitial, readUpdate } from "../lib/instructions"
|
||||||
|
|
||||||
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||||
|
|
@ -36,7 +36,7 @@ describe("InstructionBuiltIns", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* TestClock.setTime(timestamp)
|
yield* TestClock.setTime(timestamp)
|
||||||
const context = yield* InstructionBuiltIns.Service
|
const context = yield* InstructionBuiltIns.Service
|
||||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
const initialized = yield* readInitial(yield* context.load())
|
||||||
|
|
||||||
expect(initialized.text).toBe(
|
expect(initialized.text).toBe(
|
||||||
[
|
[
|
||||||
|
|
@ -54,19 +54,16 @@ describe("InstructionBuiltIns", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reconciles the date without repeating unchanged environment instructions", () =>
|
it.effect("updates the date without repeating unchanged environment instructions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* TestClock.setTime(timestamp)
|
yield* TestClock.setTime(timestamp)
|
||||||
const context = yield* InstructionBuiltIns.Service
|
const context = yield* InstructionBuiltIns.Service
|
||||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
const initialized = yield* readInitial(yield* context.load())
|
||||||
|
|
||||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||||
const refreshed = yield* Instructions.reconcile(yield* context.load(), initialized.applied)
|
const refreshed = yield* readUpdate(yield* context.load(), initialized)
|
||||||
|
|
||||||
expect(refreshed).toMatchObject({
|
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
|
||||||
_tag: "Updated",
|
|
||||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,10 +71,10 @@ describe("InstructionBuiltIns", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* TestClock.setTime(timestamp)
|
yield* TestClock.setTime(timestamp)
|
||||||
const context = yield* InstructionBuiltIns.Service
|
const context = yield* InstructionBuiltIns.Service
|
||||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
const initialized = yield* readInitial(yield* context.load())
|
||||||
|
|
||||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||||
expect(yield* Instructions.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,137 +1,142 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
import { Cause, Effect, Exit, Option, Schema } from "effect"
|
||||||
import { Instructions } from "@opencode-ai/core/instructions"
|
import { Instructions } from "@opencode-ai/core/instructions"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
|
|
||||||
const key = Instructions.Key.make
|
const key = (value: string) => Instructions.Key.make(value)
|
||||||
const stringContext = (input: {
|
const source = (input: {
|
||||||
key: string
|
key: string
|
||||||
value: string | Instructions.Unavailable
|
value: string | Instructions.Unavailable | Instructions.Removed
|
||||||
baseline?: (value: string) => string
|
initial?: (value: string) => string
|
||||||
update?: (previous: string, current: string) => string
|
changed?: (previous: string, current: string) => string
|
||||||
removed?: (value: string) => string
|
removed?: (value: string) => string
|
||||||
}) =>
|
}) =>
|
||||||
Instructions.make({
|
Instructions.make({
|
||||||
key: key(input.key),
|
key: key(input.key),
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed(input.value),
|
read: Effect.succeed(input.value),
|
||||||
baseline: input.baseline ?? String,
|
render: {
|
||||||
update: input.update ?? ((_previous, current) => current),
|
initial: input.initial ?? String,
|
||||||
removed: input.removed,
|
changed: input.changed ?? ((_previous, current) => current),
|
||||||
|
removed: input.removed,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Instructions", () => {
|
describe("Instructions", () => {
|
||||||
it.effect("stores the canonical JSON encoding of the loaded value", () =>
|
it.effect("reads each source once and derives the initial delta and text", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const context = Instructions.make({
|
let reads = 0
|
||||||
|
const instructions = Instructions.make({
|
||||||
key: key("core/date"),
|
key: key("core/date"),
|
||||||
codec: Schema.toCodecJson(Schema.DateFromString),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
|
read: Effect.sync(() => {
|
||||||
baseline: (date) => date.toISOString(),
|
reads++
|
||||||
update: (_previous, date) => date.toISOString(),
|
return "2026-07-09"
|
||||||
removed: () => "Date removed",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* Instructions.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("loads once and initializes a baseline with the applied values", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
let loads = 0
|
|
||||||
const context = Instructions.combine([
|
|
||||||
Instructions.make({
|
|
||||||
key: key("core/date"),
|
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
|
||||||
load: Effect.sync(() => {
|
|
||||||
loads++
|
|
||||||
return "2026-06-03"
|
|
||||||
}),
|
|
||||||
baseline: (date) => `Today's date is ${date}.`,
|
|
||||||
update: (previous, current) => `The date changed from ${previous} to ${current}.`,
|
|
||||||
removed: () => "The date was removed.",
|
|
||||||
}),
|
}),
|
||||||
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
|
render: {
|
||||||
])
|
initial: (date) => `Today's date: ${date}`,
|
||||||
|
changed: (previous, current) => `The date changed from ${previous} to ${current}`,
|
||||||
expect(yield* Instructions.initialize(context)).toEqual({
|
|
||||||
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
|
||||||
applied: {
|
|
||||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
|
||||||
"core/location": { value: "/repo" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(loads).toBe(1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("renders updates only after a structured value changes", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const previous = {
|
|
||||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
|
||||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
|
||||||
}
|
|
||||||
const changed = Instructions.combine([
|
|
||||||
stringContext({
|
|
||||||
key: "core/date",
|
|
||||||
value: "2026-06-04",
|
|
||||||
update: (before, current) => `The date changed from ${before} to ${current}.`,
|
|
||||||
removed: () => "The date was removed.",
|
|
||||||
}),
|
|
||||||
stringContext({ key: "core/location", value: "/repo" }),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(yield* Instructions.reconcile(changed, previous)).toEqual({
|
|
||||||
_tag: "Updated",
|
|
||||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
|
||||||
applied: {
|
|
||||||
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
|
||||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const admitted = yield* Instructions.read(instructions).pipe(Effect.flatMap(Instructions.diff))
|
||||||
|
const hash = Instructions.hash("2026-07-09")
|
||||||
|
|
||||||
|
expect(reads).toBe(1)
|
||||||
|
expect(admitted).toEqual({
|
||||||
|
delta: { "core/date": hash },
|
||||||
|
blobs: { [hash]: "2026-07-09" },
|
||||||
|
})
|
||||||
|
expect(Instructions.renderInitial(instructions, { "core/date": "2026-07-09" })).toBe("Today's date: 2026-07-09")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("derives no delta when the encoded value is unchanged", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instructions = source({ key: "core/date", value: "2026-07-09" })
|
||||||
|
const admitted = yield* Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => Instructions.diff(observed, { "core/date": Instructions.hash("2026-07-09") })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(admitted).toEqual({ delta: {}, blobs: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("renders a changed value from stored values", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instructions = source({
|
||||||
|
key: "core/date",
|
||||||
|
value: "2026-07-10",
|
||||||
|
changed: (previous, current) => `The date changed from ${previous} to ${current}`,
|
||||||
|
})
|
||||||
|
const admitted = yield* Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => Instructions.diff(observed, { "core/date": Instructions.hash("2026-07-09") })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(admitted.delta).toEqual({ "core/date": Instructions.hash("2026-07-10") })
|
||||||
expect(
|
expect(
|
||||||
yield* Instructions.reconcile(
|
Instructions.renderUpdate(
|
||||||
Instructions.combine([
|
instructions,
|
||||||
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
|
{ "core/date": "2026-07-09" },
|
||||||
stringContext({ key: "core/location", value: "/repo" }),
|
{ "core/date": Option.some("2026-07-10") },
|
||||||
]),
|
|
||||||
previous,
|
|
||||||
),
|
),
|
||||||
).toEqual({ _tag: "Unchanged" })
|
).toBe("The date changed from 2026-07-09 to 2026-07-10")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses the baseline for a newly added source", () =>
|
it.effect("admits and renders an observed removal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const context = stringContext({
|
const instructions = source({
|
||||||
key: "core/skills",
|
key: "core/remote",
|
||||||
value: "effect",
|
value: Instructions.removed,
|
||||||
baseline: (skill) => `Available skill: ${skill}`,
|
removed: (previous) => `Stop applying ${previous}`,
|
||||||
})
|
})
|
||||||
|
const admitted = yield* Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => Instructions.diff(observed, { "core/remote": Instructions.hash("instructions") })),
|
||||||
|
)
|
||||||
|
|
||||||
expect(yield* Instructions.reconcile(context, {})).toEqual({
|
expect(admitted).toEqual({ delta: { "core/remote": "removed" }, blobs: {} })
|
||||||
_tag: "Updated",
|
expect(
|
||||||
text: "Available skill: effect",
|
Instructions.renderUpdate(instructions, { "core/remote": "instructions" }, { "core/remote": Option.none() }),
|
||||||
applied: { "core/skills": { value: "effect" } },
|
).toBe("Stop applying instructions")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats JSON null as a value rather than a removal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instructions = Instructions.make<Schema.Json>({
|
||||||
|
key: key("api/value"),
|
||||||
|
codec: Schema.toCodecJson(Schema.Json),
|
||||||
|
read: Effect.succeed(null),
|
||||||
|
render: {
|
||||||
|
initial: String,
|
||||||
|
changed: (_previous, current) => String(current),
|
||||||
|
removed: () => "removed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const admitted = yield* Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => Instructions.diff(observed, { "api/value": Instructions.hash("previous") })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(admitted).toEqual({
|
||||||
|
delta: { "api/value": Instructions.hash(null) },
|
||||||
|
blobs: { [Instructions.hash(null)]: null },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
|
||||||
|
).toBe("null")
|
||||||
|
expect(Instructions.applyDelta({ "api/value": "previous" }, { "api/value": Option.some(null) })).toEqual({
|
||||||
|
"api/value": null,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("retains the belief while a source is temporarily unavailable", () =>
|
it.effect("blocks the initial delta while any source is unavailable", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
const exit = yield* Instructions.read(source({ key: "core/remote", value: Instructions.unavailable })).pipe(
|
||||||
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
|
Effect.flatMap(Instructions.diff),
|
||||||
|
Effect.exit,
|
||||||
expect(yield* Instructions.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
)
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("blocks initialization while a source is unavailable", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const exit = yield* Instructions.initialize(
|
|
||||||
stringContext({ key: "core/remote", value: Instructions.unavailable }),
|
|
||||||
).pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
if (Exit.isFailure(exit))
|
if (Exit.isFailure(exit))
|
||||||
|
|
@ -139,176 +144,89 @@ describe("Instructions", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("emits the previously stored removal message", () =>
|
it.effect("keeps the stored value while a source is unavailable mid-session", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
const admitted = yield* Instructions.read(source({ key: "core/remote", value: Instructions.unavailable })).pipe(
|
||||||
yield* Instructions.reconcile(Instructions.empty, {
|
Effect.flatMap((observed) => Instructions.diff(observed, { "core/remote": Instructions.hash("instructions") })),
|
||||||
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
|
)
|
||||||
}),
|
|
||||||
).toEqual({
|
expect(admitted).toEqual({ delta: {}, blobs: {} })
|
||||||
_tag: "Updated",
|
|
||||||
text: "Instructions removed; stop applying them.",
|
|
||||||
applied: {},
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("retains an unannounced removal silently", () =>
|
it.effect("does not infer removal when a source is absent from the current version", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(yield* Instructions.reconcile(Instructions.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
const admitted = yield* Instructions.read(Instructions.empty).pipe(
|
||||||
_tag: "Unchanged",
|
Effect.flatMap((observed) =>
|
||||||
})
|
Instructions.diff(observed, { "core/retired": Instructions.hash("old instructions") }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// The retained belief survives alongside other updates.
|
expect(admitted).toEqual({ delta: {}, blobs: {} })
|
||||||
expect(
|
|
||||||
yield* Instructions.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
|
||||||
"core/date": { value: "2026-06-04" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
_tag: "Updated",
|
|
||||||
text: "effect",
|
|
||||||
applied: {
|
|
||||||
"core/skills": { value: "effect" },
|
|
||||||
"core/date": { value: "2026-06-04" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("renders multiple removals in stable key order", () =>
|
it.effect("renders a newly added source with its initial renderer", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
const instructions = source({
|
||||||
yield* Instructions.reconcile(Instructions.empty, {
|
key: "core/skills",
|
||||||
"core/z": { value: "z", removed: "Removed z" },
|
value: "effect",
|
||||||
"core/a": { value: "a", removed: "Removed a" },
|
initial: (skill) => `Available skill: ${skill}`,
|
||||||
}),
|
})
|
||||||
).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" })
|
|
||||||
|
expect(Instructions.renderUpdate(instructions, {}, { "core/skills": Option.some("effect") })).toBe(
|
||||||
|
"Available skill: effect",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("hashes objects independently of key order", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(Instructions.hash({ a: 1, b: { x: true, y: false } })).toBe(
|
||||||
|
Instructions.hash({ b: { y: false, x: true }, a: 1 }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("renders sources in composition order", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const instructions = Instructions.combine([
|
||||||
|
source({ key: "core/date", value: "date" }),
|
||||||
|
source({ key: "core/location", value: "location" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })).toBe(
|
||||||
|
"date\n\nlocation",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects duplicate source keys", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(() =>
|
||||||
|
Instructions.combine([source({ key: "core/date", value: "one" }), source({ key: "core/date", value: "two" })]),
|
||||||
|
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects empty model-visible renderings", () =>
|
it.effect("rejects empty model-visible renderings", () =>
|
||||||
Effect.gen(function* () {
|
Effect.sync(() => {
|
||||||
const exit = yield* Instructions.initialize(
|
const instructions = source({ key: "core/empty", value: "value", initial: () => "" })
|
||||||
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
|
|
||||||
).pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(() => Instructions.renderInitial(instructions, { "core/empty": "value" })).toThrow(
|
||||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline")
|
"Instruction source core/empty rendered an empty initial",
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
|
it.effect("diffs list values by key", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(
|
|
||||||
yield* Instructions.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
|
||||||
"core/date": { value: 42, removed: "Date removed" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
_tag: "Updated",
|
|
||||||
text: "2026-06-04",
|
|
||||||
applied: { "core/date": { value: "2026-06-04" } },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("renders undecodable re-announcements alongside other updates", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const context = Instructions.combine([
|
|
||||||
stringContext({
|
|
||||||
key: "core/date",
|
|
||||||
value: "2026-06-04",
|
|
||||||
update: (before, current) => `${before} -> ${current}`,
|
|
||||||
}),
|
|
||||||
stringContext({ key: "core/location", value: "/repo" }),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* Instructions.reconcile(context, {
|
|
||||||
"core/date": { value: "2026-06-03" },
|
|
||||||
"core/location": { value: 42 },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
_tag: "Updated",
|
|
||||||
text: "2026-06-03 -> 2026-06-04\n\n/repo",
|
|
||||||
applied: {
|
|
||||||
"core/date": { value: "2026-06-04" },
|
|
||||||
"core/location": { value: "/repo" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rebaselines from one coherent source observation", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
let loads = 0
|
|
||||||
const context = Instructions.make({
|
|
||||||
key: key("core/date"),
|
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
|
||||||
load: Effect.sync(() => {
|
|
||||||
loads++
|
|
||||||
return "2026-06-04"
|
|
||||||
}),
|
|
||||||
baseline: String,
|
|
||||||
update: (_previous, current) => current,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(yield* Instructions.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
|
||||||
text: "2026-06-04",
|
|
||||||
applied: { "core/date": { value: "2026-06-04" } },
|
|
||||||
})
|
|
||||||
expect(loads).toBe(1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const context = Instructions.combine([
|
|
||||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
|
||||||
stringContext({
|
|
||||||
key: "core/remote",
|
|
||||||
value: Instructions.unavailable,
|
|
||||||
baseline: (value) => `Instructions: ${value}`,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* Instructions.rebaseline(context, {
|
|
||||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
text: "2026-06-04\n\nInstructions: contents",
|
|
||||||
applied: {
|
|
||||||
"core/date": { value: "2026-06-04" },
|
|
||||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
|
|
||||||
|
|
||||||
// Undecodable belief cannot be restated; removed source entries self-clean.
|
|
||||||
expect(
|
|
||||||
yield* Instructions.rebaseline(context, {
|
|
||||||
"core/remote": { value: 42 },
|
|
||||||
"core/gone": { value: "gone" },
|
|
||||||
}),
|
|
||||||
).toEqual({ text: "", applied: {} })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("diffs list values by key with a changed comparator", () =>
|
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const previous = [
|
const previous = [
|
||||||
{ name: "effect", description: "Build with Effect" },
|
{ name: "effect", description: "Build with Effect" },
|
||||||
{ name: "debugging", description: "Diagnose bugs" },
|
|
||||||
{ name: "retired", description: "Old" },
|
{ name: "retired", description: "Old" },
|
||||||
]
|
]
|
||||||
const current = [
|
const current = [
|
||||||
{ name: "effect", description: "Build with Effect v4" },
|
{ name: "effect", description: "Build with Effect v4" },
|
||||||
{ name: "debugging", description: "Diagnose bugs" },
|
|
||||||
{ name: "writing", description: "Write prose" },
|
{ name: "writing", description: "Write prose" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -331,47 +249,4 @@ describe("Instructions", () => {
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects duplicate source keys", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
expect(() =>
|
|
||||||
Instructions.combine([
|
|
||||||
stringContext({ key: "core/date", value: "one" }),
|
|
||||||
stringContext({ key: "core/date", value: "two" }),
|
|
||||||
]),
|
|
||||||
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("combines instructions in order", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(
|
|
||||||
(yield* Instructions.initialize(
|
|
||||||
Instructions.combine([
|
|
||||||
stringContext({ key: "core/date", value: "date" }),
|
|
||||||
stringContext({ key: "core/location", value: "location" }),
|
|
||||||
]),
|
|
||||||
)).text,
|
|
||||||
).toBe("date\n\nlocation")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("requires namespaced source keys", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const decodeKey = Schema.decodeUnknownSync(Instructions.Key)
|
|
||||||
|
|
||||||
expect(decodeKey("core/date")).toBe(key("core/date"))
|
|
||||||
expect(() => decodeKey("date")).toThrow()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("requires namespaced applied keys", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const decodeApplied = Schema.decodeUnknownSync(Instructions.Applied)
|
|
||||||
|
|
||||||
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
|
||||||
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()
|
|
||||||
expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
43
packages/core/test/lib/instructions.ts
Normal file
43
packages/core/test/lib/instructions.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Effect, Option, Schema } from "effect"
|
||||||
|
import { Instructions } from "@opencode-ai/core/instructions"
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
readonly values: Readonly<Record<string, Schema.Json>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const state = (values: Readonly<Record<string, Schema.Json>>): State => ({ values })
|
||||||
|
|
||||||
|
const hashes = (values: Readonly<Record<string, Schema.Json>>): Instructions.Values =>
|
||||||
|
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Instructions.hash(value)]))
|
||||||
|
|
||||||
|
export const readInitial = (instructions: Instructions.Instructions) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const admission = yield* Instructions.read(instructions).pipe(Effect.flatMap(Instructions.diff))
|
||||||
|
const current = state(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(admission.delta).flatMap(([key, hash]) =>
|
||||||
|
hash === "removed" ? [] : [[key, admission.blobs[hash]]],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return { ...current, text: Instructions.renderInitial(instructions, current.values) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const readUpdate = (instructions: Instructions.Instructions, previous: State) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const admission = yield* Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => Instructions.diff(observed, hashes(previous.values))),
|
||||||
|
)
|
||||||
|
const delta = Object.fromEntries(
|
||||||
|
Object.entries(admission.delta).map(([key, hash]) => [
|
||||||
|
key,
|
||||||
|
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
|
||||||
|
]),
|
||||||
|
) as Readonly<Record<string, Option.Option<Schema.Json>>>
|
||||||
|
const values = Instructions.applyDelta(previous.values, delta)
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
text: Instructions.renderUpdate(instructions, previous.values, delta),
|
||||||
|
changed: Object.keys(admission.delta).length > 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -13,14 +13,8 @@ export const toolIdentity = {
|
||||||
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
|
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fixture model: a non-OpenAI provider, so edit and write are the materialized edit tools.
|
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
|
||||||
export const testModel: ToolRegistry.MaterializeInput["model"] = { id: "claude-test", provider: "anthropic" }
|
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
|
||||||
|
|
||||||
export const toolDefinitions = (
|
|
||||||
registry: ToolRegistry.Interface,
|
|
||||||
permissions?: PermissionV2.Ruleset,
|
|
||||||
model = testModel,
|
|
||||||
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
|
|
||||||
|
|
||||||
export function waitForTool(
|
export function waitForTool(
|
||||||
registry: ToolRegistry.Interface,
|
registry: ToolRegistry.Interface,
|
||||||
|
|
@ -76,8 +70,8 @@ export const registerToolPlugin = <R>(plugin: {
|
||||||
yield* plugin.effect(context)
|
yield* plugin.effect(context)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||||
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||||
|
|
||||||
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||||
settleTool(registry, input, model).pipe(Effect.map((settlement) => settlement.result))
|
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
|
||||||
import { Config } from "@opencode-ai/schema/config"
|
import { Config } from "@opencode-ai/schema/config"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
|
@ -706,7 +706,7 @@ describe("LocationServiceMap", () => {
|
||||||
})
|
})
|
||||||
.pipe(Effect.asVoid),
|
.pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
yield* plugins.activate([{ plugin: reviewer }])
|
yield* plugins.activate([reviewer])
|
||||||
|
|
||||||
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||||
description: "Reviews code",
|
description: "Reviews code",
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||||
|
|
@ -47,7 +47,9 @@ type ResourceTemplatePage = {
|
||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
|
function resourceServer(
|
||||||
|
input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
|
||||||
|
) {
|
||||||
return Effect.acquireRelease(
|
return Effect.acquireRelease(
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -71,7 +73,42 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
protocol.setRequestHandler(ListToolsRequestSchema, () =>
|
||||||
|
Promise.resolve({
|
||||||
|
tools: input.emptyElicitation
|
||||||
|
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||||
|
: input.urlElicitation
|
||||||
|
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (input.emptyElicitation) {
|
||||||
|
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||||
|
const result = await protocol.elicitInput({
|
||||||
|
mode: "form",
|
||||||
|
message: "Confirm",
|
||||||
|
requestedSchema: { type: "object", properties: {} },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (input.urlElicitation) {
|
||||||
|
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||||
|
const result = await protocol.elicitInput({
|
||||||
|
mode: "url",
|
||||||
|
message: "Authorize access",
|
||||||
|
url: "https://example.com/authorize",
|
||||||
|
elicitationId: "elicitation-test",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (input.resources !== false) {
|
if (input.resources !== false) {
|
||||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||||
state.resourceLists += 1
|
state.resourceLists += 1
|
||||||
|
|
@ -98,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
||||||
state,
|
state,
|
||||||
url: http.url.toString(),
|
url: http.url.toString(),
|
||||||
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
||||||
|
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await protocol.close().catch(() => {})
|
await protocol.close().catch(() => {})
|
||||||
await http.stop(true)
|
await http.stop(true)
|
||||||
|
|
@ -108,10 +146,11 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceMcpLayer(url: string) {
|
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
||||||
const directory = AbsolutePath.make(import.meta.dir)
|
const directory = AbsolutePath.make(import.meta.dir)
|
||||||
const unusedIntegration = () => Effect.die("unused integration service")
|
const unusedIntegration = () => Effect.die("unused integration service")
|
||||||
return MCP.layer.pipe(
|
return MCP.layer.pipe(
|
||||||
|
Layer.provideMerge(Form.layer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
|
|
@ -133,14 +172,16 @@ function resourceMcpLayer(url: string) {
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
||||||
Layer.mock(EventV2.Service, {
|
Layer.mock(EventV2.Service, {
|
||||||
subscribe: () => Stream.never,
|
subscribe: () => Stream.never,
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) => {
|
||||||
Effect.succeed({
|
const event = {
|
||||||
id: EventV2.ID.create(),
|
id: EventV2.ID.create(),
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
data,
|
data,
|
||||||
} as EventV2.Payload<typeof definition>),
|
} as EventV2.Payload<typeof definition>
|
||||||
|
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
|
||||||
|
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Layer.mock(Form.Service, {}),
|
|
||||||
Layer.mock(Integration.Service, {
|
Layer.mock(Integration.Service, {
|
||||||
connection: {
|
connection: {
|
||||||
active: unusedIntegration,
|
active: unusedIntegration,
|
||||||
|
|
@ -490,6 +531,53 @@ test("skips MCP resource requests when the capability is absent", async () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts empty MCP elicitations without creating forms", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
||||||
|
const result = yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
||||||
|
expect(yield* forms.list()).toEqual([])
|
||||||
|
return result
|
||||||
|
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ action: "accept", content: {} })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
||||||
|
const created = yield* Deferred.make<Form.Info>()
|
||||||
|
const result = yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
const form = yield* Deferred.await(created)
|
||||||
|
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
|
||||||
|
|
||||||
|
yield* Effect.promise(server.completeElicitation)
|
||||||
|
const result = yield* Fiber.join(call)
|
||||||
|
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
|
||||||
|
return result
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ action: "accept" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("loads and reads MCP resources", async () => {
|
test("loads and reads MCP resources", async () => {
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { $ } from "bun"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
|
@ -15,12 +15,26 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
// Records the execution serialization a move must perform before relocating.
|
||||||
|
const executionCalls: string[] = []
|
||||||
|
const recordingExecution = Layer.succeed(
|
||||||
|
SessionExecution.Service,
|
||||||
|
SessionExecution.Service.of({
|
||||||
|
active: Effect.succeed(new Set()),
|
||||||
|
resume: () => Effect.void,
|
||||||
|
wake: () => Effect.void,
|
||||||
|
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
|
||||||
|
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([
|
LayerNode.group([
|
||||||
|
|
@ -32,6 +46,7 @@ const it = testEffect(
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
]),
|
]),
|
||||||
|
[[SessionExecution.node, recordingExecution]],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,10 +107,13 @@ describe("MoveSession", () => {
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
executionCalls.length = 0
|
||||||
yield* MoveSession.Service.use((service) =>
|
yield* MoveSession.Service.use((service) =>
|
||||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The move stops active execution before any relocation side effect.
|
||||||
|
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
|
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { Message, SystemPart } from "@opencode-ai/llm"
|
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { PluginHooks } from "../src/plugin/hooks"
|
|
||||||
|
|
||||||
describe("PluginHooks", () => {
|
|
||||||
it("registers scoped domain hooks and triggers them sequentially", async () => {
|
|
||||||
const seen: string[] = []
|
|
||||||
const program = Effect.gen(function* () {
|
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
yield* hooks.register("session", "request", (event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
seen.push("first")
|
|
||||||
event.system.push(SystemPart.make("second"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* hooks.register("session", "request", (event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
seen.push(event.system[1]?.text ?? "missing")
|
|
||||||
event.messages = [Message.user("changed")]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const event = {
|
|
||||||
sessionID: Session.ID.make("ses_hooks"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
|
||||||
system: [SystemPart.make("first")],
|
|
||||||
messages: [Message.user("original")],
|
|
||||||
tools: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
|
||||||
expect(seen).toEqual(["first", "second"])
|
|
||||||
expect(event.messages).toEqual([Message.user("changed")])
|
|
||||||
})
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
|
||||||
Effect.scoped(program).pipe(
|
|
||||||
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { testModel } from "./lib/tool"
|
|
||||||
import { PluginTestLayer } from "./plugin/fixture"
|
import { PluginTestLayer } from "./plugin/fixture"
|
||||||
|
|
||||||
const it = testEffect(PluginTestLayer)
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
@ -38,7 +37,7 @@ describe("PluginV2", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("skips identical generations and replaces changed plugin versions", () =>
|
it.effect("replaces plugins by ID", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugins = yield* PluginV2.Service
|
const plugins = yield* PluginV2.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
|
|
@ -61,15 +60,12 @@ describe("PluginV2", () => {
|
||||||
.pipe(Effect.asVoid),
|
.pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin: managed() }])
|
yield* plugins.activate([managed()])
|
||||||
|
|
||||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||||
|
|
||||||
description = "second"
|
description = "second"
|
||||||
yield* plugins.activate([{ plugin: managed() }])
|
yield* plugins.activate([managed()])
|
||||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin: managed(), version: "next" }])
|
|
||||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||||
expect(yield* Fiber.join(updated)).toHaveLength(2)
|
expect(yield* Fiber.join(updated)).toHaveLength(2)
|
||||||
|
|
||||||
|
|
@ -78,17 +74,17 @@ describe("PluginV2", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects duplicate IDs before replacing the active generation", () =>
|
it.effect("rejects duplicate IDs before replacing active plugins", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugins = yield* PluginV2.Service
|
const plugins = yield* PluginV2.Service
|
||||||
const active = Plugin.ID.make("active")
|
const active = Plugin.ID.make("active")
|
||||||
const duplicate = "duplicate"
|
const duplicate = "duplicate"
|
||||||
yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }])
|
yield* plugins.activate([{ id: active, effect: () => Effect.void }])
|
||||||
|
|
||||||
const result = yield* plugins
|
const result = yield* plugins
|
||||||
.activate([
|
.activate([
|
||||||
{ plugin: { id: duplicate, effect: () => Effect.void } },
|
{ id: duplicate, effect: () => Effect.void },
|
||||||
{ plugin: { id: duplicate, effect: () => Effect.void } },
|
{ id: duplicate, effect: () => Effect.void },
|
||||||
])
|
])
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
|
|
||||||
|
|
@ -121,16 +117,81 @@ describe("PluginV2", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
|
yield* plugins.activate([good, bad])
|
||||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
||||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
|
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
|
||||||
|
|
||||||
fail = false
|
fail = false
|
||||||
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
|
yield* plugins.activate([good, bad])
|
||||||
expect(yield* plugins.list()).toEqual([
|
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
|
||||||
{ id: Plugin.ID.make("good") },
|
}),
|
||||||
{ id: Plugin.ID.make("bad") },
|
)
|
||||||
])
|
|
||||||
|
it.effect("restores the previous plugin when its replacement fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
const previous = EffectPlugin.define({
|
||||||
|
id: "managed",
|
||||||
|
effect: (ctx) =>
|
||||||
|
ctx.agent
|
||||||
|
.transform((agents) =>
|
||||||
|
agents.update("configured", (agent) => {
|
||||||
|
agent.description = "previous"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.asVoid),
|
||||||
|
})
|
||||||
|
const replacement = EffectPlugin.define({
|
||||||
|
id: "managed",
|
||||||
|
effect: (ctx) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ctx.agent.transform((agents) =>
|
||||||
|
agents.update("configured", (agent) => {
|
||||||
|
agent.description = "replacement"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return yield* Effect.die(new Error("replacement failed"))
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* plugins.activate([previous])
|
||||||
|
yield* plugins.activate([replacement])
|
||||||
|
|
||||||
|
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
|
||||||
|
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("previous")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("deactivates a plugin when replacement and restoration fail", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
let loads = 0
|
||||||
|
const previous = EffectPlugin.define({
|
||||||
|
id: "managed",
|
||||||
|
effect: (ctx) => {
|
||||||
|
loads++
|
||||||
|
if (loads > 1) return Effect.die(new Error("restoration failed"))
|
||||||
|
return ctx.agent
|
||||||
|
.transform((agents) =>
|
||||||
|
agents.update("configured", (agent) => {
|
||||||
|
agent.description = "previous"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const replacement = EffectPlugin.define({
|
||||||
|
id: "managed",
|
||||||
|
effect: () => Effect.die(new Error("replacement failed")),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* plugins.activate([previous])
|
||||||
|
yield* plugins.activate([replacement])
|
||||||
|
|
||||||
|
expect(yield* plugins.list()).toEqual([])
|
||||||
|
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -140,10 +201,8 @@ describe("PluginV2", () => {
|
||||||
const closed: string[] = []
|
const closed: string[] = []
|
||||||
yield* plugins.activate(
|
yield* plugins.activate(
|
||||||
["first", "second"].map((id) => ({
|
["first", "second"].map((id) => ({
|
||||||
plugin: {
|
id,
|
||||||
id,
|
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
||||||
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -166,7 +225,7 @@ describe("PluginV2", () => {
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin }]).pipe(Effect.provideService(Secret, "secret"))
|
yield* plugins.activate([plugin]).pipe(Effect.provideService(Secret, "secret"))
|
||||||
|
|
||||||
expect(visible).toBe(false)
|
expect(visible).toBe(false)
|
||||||
}),
|
}),
|
||||||
|
|
@ -194,15 +253,11 @@ describe("PluginV2", () => {
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin }])
|
yield* plugins.activate([plugin])
|
||||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
|
||||||
"plugin_tool",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* plugins.activate([])
|
yield* plugins.activate([])
|
||||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
|
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
|
||||||
"plugin_tool",
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -229,9 +284,9 @@ describe("PluginV2", () => {
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin }])
|
yield* plugins.activate([plugin])
|
||||||
|
|
||||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
|
||||||
"plain",
|
"plain",
|
||||||
"context_7_look_up",
|
"context_7_look_up",
|
||||||
"execute",
|
"execute",
|
||||||
|
|
@ -288,9 +343,9 @@ describe("PluginV2", () => {
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin }])
|
yield* plugins.activate([plugin])
|
||||||
|
|
||||||
const materialized = yield* registry.materialize({ model: testModel })
|
const materialized = yield* registry.materialize()
|
||||||
const settlement = yield* materialized.settle({
|
const settlement = yield* materialized.settle({
|
||||||
sessionID: SessionV2.ID.make("ses_hooks"),
|
sessionID: SessionV2.ID.make("ses_hooks"),
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,6 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||||
prompt: () => Effect.die("unused session.prompt"),
|
prompt: () => Effect.die("unused session.prompt"),
|
||||||
command: () => Effect.die("unused session.command"),
|
command: () => Effect.die("unused session.command"),
|
||||||
interrupt: () => Effect.die("unused session.interrupt"),
|
interrupt: () => Effect.die("unused session.interrupt"),
|
||||||
// Plugins register session hooks during setup, so a bare host accepts the
|
|
||||||
// registration; the callback only runs when a test triggers the request pipeline.
|
|
||||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
@ -93,4 +96,66 @@ describe("fromPromise", () => {
|
||||||
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("runs the setup cleanup when the plugin scope closes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
const events: string[] = []
|
||||||
|
const promisePlugin = Plugin.define({
|
||||||
|
id: "promise-cleanup",
|
||||||
|
setup: async () => {
|
||||||
|
events.push("setup")
|
||||||
|
return async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
events.push("cleanup")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||||
|
expect(events).toEqual(["setup"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toEqual(["setup", "cleanup"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("constructs plain Promise tool declarations in the host", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const host = yield* PluginHost.make(plugins)
|
||||||
|
const promisePlugin = Plugin.define({
|
||||||
|
id: "promise-tool",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.tool.transform((tools) => {
|
||||||
|
tools.add({
|
||||||
|
name: "hello",
|
||||||
|
description: "Hello",
|
||||||
|
input: Schema.Struct({ name: Schema.String }),
|
||||||
|
output: Schema.String,
|
||||||
|
execute: async ({ name }) => `Hello, ${name}!`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||||
|
|
||||||
|
const materialized = yield* registry.materialize()
|
||||||
|
expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
|
||||||
|
expect(
|
||||||
|
yield* materialized.settle({
|
||||||
|
sessionID: SessionV2.ID.make("ses_promise_tool"),
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
assistantMessageID: SessionMessage.ID.make("msg_promise_tool"),
|
||||||
|
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Reference } from "@opencode-ai/core/reference"
|
import { Reference } from "@opencode-ai/core/reference"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
import { readInitial, readUpdate } from "./lib/instructions"
|
||||||
|
|
||||||
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
||||||
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
|
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
|
||||||
|
|
@ -14,7 +14,7 @@ describe("ReferenceGuidance", () => {
|
||||||
it.effect("lists available references in the instructions", () =>
|
it.effect("lists available references in the instructions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
const generation = yield* readInitial(yield* guidance.load())
|
||||||
|
|
||||||
expect(generation.text).toContain("<available_references>")
|
expect(generation.text).toContain("<available_references>")
|
||||||
expect(generation.text).toContain("<name>docs</name>")
|
expect(generation.text).toContain("<name>docs</name>")
|
||||||
|
|
@ -46,7 +46,7 @@ describe("ReferenceGuidance", () => {
|
||||||
it.effect("omits guidance when no references are available", () =>
|
it.effect("omits guidance when no references are available", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
const generation = yield* readInitial(yield* guidance.load())
|
||||||
expect(generation.text).toBe("")
|
expect(generation.text).toBe("")
|
||||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||||
)
|
)
|
||||||
|
|
@ -54,7 +54,7 @@ describe("ReferenceGuidance", () => {
|
||||||
it.effect("omits references without descriptions", () =>
|
it.effect("omits references without descriptions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
const generation = yield* readInitial(yield* guidance.load())
|
||||||
expect(generation.text).toBe("")
|
expect(generation.text).toBe("")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
|
|
@ -85,13 +85,12 @@ describe("ReferenceGuidance", () => {
|
||||||
let references = [reference("docs", "Use for product documentation")]
|
let references = [reference("docs", "Use for product documentation")]
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const initialized = yield* Instructions.initialize(yield* guidance.load())
|
const initialized = yield* readInitial(yield* guidance.load())
|
||||||
|
|
||||||
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
||||||
const added = yield* Instructions.reconcile(yield* guidance.load(), initialized.applied)
|
const added = yield* readUpdate(yield* guidance.load(), initialized)
|
||||||
expect(added).toMatchObject({
|
expect(added.text).toBe(
|
||||||
_tag: "Updated",
|
[
|
||||||
text: [
|
|
||||||
"New project references are available in addition to those previously listed:",
|
"New project references are available in addition to those previously listed:",
|
||||||
" <reference>",
|
" <reference>",
|
||||||
" <name>examples</name>",
|
" <name>examples</name>",
|
||||||
|
|
@ -99,15 +98,12 @@ describe("ReferenceGuidance", () => {
|
||||||
" <description>Use for examples</description>",
|
" <description>Use for examples</description>",
|
||||||
" </reference>",
|
" </reference>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
})
|
)
|
||||||
|
|
||||||
references = [reference("examples", "Use for examples")]
|
references = [reference("examples", "Use for examples")]
|
||||||
expect(
|
expect((yield* readUpdate(yield* guidance.load(), added)).text).toBe(
|
||||||
yield* Instructions.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
"The following project references are no longer available and must not be used: docs.",
|
||||||
).toMatchObject({
|
)
|
||||||
_tag: "Updated",
|
|
||||||
text: "The following project references are no longer available and must not be used: docs.",
|
|
||||||
})
|
|
||||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
|
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
|
@ -106,7 +106,7 @@ describe("SessionV2.compact", () => {
|
||||||
|
|
||||||
expect(second.id).toBe(first.id)
|
expect(second.id).toBe(first.id)
|
||||||
expect(requests).toHaveLength(0)
|
expect(requests).toHaveLength(0)
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
|
||||||
id: first.id,
|
id: first.id,
|
||||||
})
|
})
|
||||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||||
|
|
|
||||||
|
|
@ -147,10 +147,11 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||||
messages: [userMessage],
|
messages: [userMessage],
|
||||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||||
}),
|
}),
|
||||||
).toBe(true)
|
).toEqual({ status: "completed" })
|
||||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||||
|
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.generation).toBeUndefined()
|
||||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||||
expect(yield* store.context(sessionID)).toMatchObject([
|
expect(yield* store.context(sessionID)).toMatchObject([
|
||||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
|
@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
|
||||||
text: "First",
|
text: "First",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
|
|
||||||
const forked = yield* session.fork({ sessionID: parent.id })
|
const forked = yield* session.fork({ sessionID: parent.id })
|
||||||
const parentContext = yield* session.context(parent.id)
|
const parentContext = yield* session.context(parent.id)
|
||||||
|
|
@ -217,30 +217,28 @@ describe("SessionV2.create", () => {
|
||||||
durable: { seq: 0 },
|
durable: { seq: 0 },
|
||||||
data: { sessionID: forked.id, parentID: parent.id },
|
data: { sessionID: forked.id, parentID: parent.id },
|
||||||
})
|
})
|
||||||
expect(yield* SessionInput.find(db, forkContext[0].id)).toMatchObject({
|
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
||||||
sessionID: forked.id,
|
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
||||||
type: "user",
|
// Fork-copied messages have no admitted event in the fork aggregate, so
|
||||||
data: { text: "First" },
|
// reusing their IDs as prompt IDs is conflicting reuse, not a retry.
|
||||||
promotedSeq: 2,
|
expect(
|
||||||
})
|
yield* session
|
||||||
expect(yield* SessionInput.find(db, forkContext[1].id)).toMatchObject({
|
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
|
||||||
sessionID: forked.id,
|
.pipe(Effect.flip),
|
||||||
type: "synthetic",
|
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
||||||
data: { text: "parent note" },
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
text: "Parent changed",
|
text: "Parent changed",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: forked.id,
|
sessionID: forked.id,
|
||||||
text: "Child continues",
|
text: "Child continues",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
yield* SessionPending.promoteSteers(db, events, forked.id)
|
||||||
|
|
||||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||||
|
|
@ -250,7 +248,7 @@ describe("SessionV2.create", () => {
|
||||||
(event): number | undefined => event.durable?.seq,
|
(event): number | undefined => event.durable?.seq,
|
||||||
),
|
),
|
||||||
).toEqual([0, 5, 6])
|
).toEqual([0, 5, 6])
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
|
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -265,13 +263,13 @@ describe("SessionV2.create", () => {
|
||||||
text: "First",
|
text: "First",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
const second = yield* session.prompt({
|
const second = yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
text: "Second",
|
text: "Second",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
|
|
@ -416,7 +414,7 @@ describe("SessionV2.create", () => {
|
||||||
text: "Hello",
|
text: "Hello",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
yield* SessionPending.promoteSteers(db, events, created.id)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
|
|
@ -442,7 +440,7 @@ describe("SessionV2.create", () => {
|
||||||
text: "Replay lifecycle",
|
text: "Replay lifecycle",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||||
const serialized = (yield* sourceDb
|
const serialized = (yield* sourceDb
|
||||||
.select()
|
.select()
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
|
|
@ -480,7 +478,7 @@ describe("SessionV2.create", () => {
|
||||||
|
|
||||||
expect(yield* store.get(created.id)).toBeUndefined()
|
expect(yield* store.get(created.id)).toBeUndefined()
|
||||||
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
|
||||||
id: admitted.id,
|
id: admitted.id,
|
||||||
sessionID: created.id,
|
sessionID: created.id,
|
||||||
type: "user",
|
type: "user",
|
||||||
|
|
@ -491,15 +489,7 @@ describe("SessionV2.create", () => {
|
||||||
expect(yield* store.context(created.id)).toEqual([])
|
expect(yield* store.context(created.id)).toEqual([])
|
||||||
|
|
||||||
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
|
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
|
||||||
id: admitted.id,
|
|
||||||
sessionID: created.id,
|
|
||||||
type: "user",
|
|
||||||
data: { text: "Replay lifecycle" },
|
|
||||||
delivery: "steer",
|
|
||||||
admittedSeq: 1,
|
|
||||||
promotedSeq: 2,
|
|
||||||
})
|
|
||||||
expect(yield* store.context(created.id)).toMatchObject([
|
expect(yield* store.context(created.id)).toMatchObject([
|
||||||
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
ToolFailure,
|
ToolFailure,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||||
|
|
||||||
|
|
@ -64,6 +65,10 @@ describe("toSessionError", () => {
|
||||||
type: "permission.rejected",
|
type: "permission.rejected",
|
||||||
message: "Permission denied: external_directory",
|
message: "Permission denied: external_directory",
|
||||||
})
|
})
|
||||||
|
expect(toSessionError(new Tool.Failure({ message: "failed" }))).toEqual({
|
||||||
|
type: "tool.execution",
|
||||||
|
message: "failed",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { tempLocationLayer } from "./fixture/location"
|
import { tempLocationLayer } from "./fixture/location"
|
||||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
|
import { registerToolPlugin, settleTool } from "./lib/tool"
|
||||||
|
|
||||||
const readToolNode = makeLocationNode({
|
const readToolNode = makeLocationNode({
|
||||||
name: "test/read-tool-plugin",
|
name: "test/read-tool-plugin",
|
||||||
|
|
@ -162,7 +162,7 @@ describe("SessionInstructions", () => {
|
||||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
||||||
// excluding the Location root (already supplied by the core/instructions baseline).
|
// excluding the Location root (already supplied by core initial instructions).
|
||||||
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||||
|
|
||||||
const firstInjected = yield* synthetics(sessionID)
|
const firstInjected = yield* synthetics(sessionID)
|
||||||
|
|
@ -235,7 +235,7 @@ describe("SessionInstructions", () => {
|
||||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||||
// the Location root (already supplied by the core/instructions baseline).
|
// the Location root (already supplied by core initial instructions).
|
||||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||||
|
|
||||||
const firstInjected = yield* synthetics(sessionID)
|
const firstInjected = yield* synthetics(sessionID)
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { fromRow } from "@opencode-ai/core/session/info"
|
import { fromRow } from "@opencode-ai/core/session/info"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { Shell } from "@opencode-ai/schema/shell"
|
import { Shell } from "@opencode-ai/schema/shell"
|
||||||
import {
|
import {
|
||||||
InstructionCheckpointTable,
|
InstructionStateTable,
|
||||||
SessionInputTable,
|
SessionPendingTable,
|
||||||
SessionMessageTable,
|
SessionMessageTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
} from "@opencode-ai/core/session/sql"
|
} from "@opencode-ai/core/session/sql"
|
||||||
|
|
@ -77,7 +77,7 @@ describe("SessionProjector", () => {
|
||||||
.run()
|
.run()
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
||||||
yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID })
|
yield* SessionPending.admitCompaction(db, events, { id: inputID, sessionID })
|
||||||
|
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -85,7 +85,7 @@ describe("SessionProjector", () => {
|
||||||
error: { type: "compaction.failed", message: "Auto compaction failed" },
|
error: { type: "compaction.failed", message: "Auto compaction failed" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID })
|
expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -204,8 +204,14 @@ describe("SessionProjector", () => {
|
||||||
])
|
])
|
||||||
.run()
|
.run()
|
||||||
yield* db
|
yield* db
|
||||||
.insert(InstructionCheckpointTable)
|
.insert(InstructionStateTable)
|
||||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
.values({
|
||||||
|
session_id: sessionID,
|
||||||
|
epoch_start: 0,
|
||||||
|
through_seq: 0,
|
||||||
|
initial_values: {},
|
||||||
|
current_values: {},
|
||||||
|
})
|
||||||
.run()
|
.run()
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
|
|
@ -238,8 +244,8 @@ describe("SessionProjector", () => {
|
||||||
tokens_cache_read: 3,
|
tokens_cache_read: 3,
|
||||||
tokens_cache_write: 1,
|
tokens_cache_write: 1,
|
||||||
})
|
})
|
||||||
// A committed revert resets the context checkpoint so the next turn re-initializes.
|
// A committed revert resets the fold cache so the next boundary establishes a new epoch.
|
||||||
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -316,7 +322,7 @@ describe("SessionProjector", () => {
|
||||||
}).pipe(Effect.provide(sessionsLayer)),
|
}).pipe(Effect.provide(sessionsLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () =>
|
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
|
|
@ -338,7 +344,7 @@ describe("SessionProjector", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const id = SessionMessage.ID.make("msg_admitted")
|
const id = SessionMessage.ID.make("msg_admitted")
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
|
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
|
||||||
|
|
@ -351,8 +357,11 @@ describe("SessionProjector", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
|
||||||
).toMatchObject({ promoted_seq: event.durable?.seq })
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||||
|
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
|
@ -81,11 +81,11 @@ const setup = Effect.gen(function* () {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
|
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
|
||||||
const admittedCount = Database.Service.use(({ db }) =>
|
const admittedCount = Database.Service.use(({ db }) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.all()
|
.all()
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
|
|
@ -201,7 +201,7 @@ describe("SessionV2.prompt", () => {
|
||||||
text: "boundary",
|
text: "boundary",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
||||||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
|
|
@ -218,7 +218,7 @@ describe("SessionV2.prompt", () => {
|
||||||
(row) => row.id,
|
(row) => row.id,
|
||||||
),
|
),
|
||||||
).not.toContainAnyValues([boundary.id, stale])
|
).not.toContainAnyValues([boundary.id, stale])
|
||||||
expect(yield* SessionInput.find(db, boundary.id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,7 +233,7 @@ describe("SessionV2.prompt", () => {
|
||||||
text: "boundary",
|
text: "boundary",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
sessionID,
|
sessionID,
|
||||||
revert: { messageID: boundary.id, files: [] },
|
revert: { messageID: boundary.id, files: [] },
|
||||||
|
|
@ -243,11 +243,11 @@ describe("SessionV2.prompt", () => {
|
||||||
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
|
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
|
||||||
|
|
||||||
expect(wakeCalls).toEqual([])
|
expect(wakeCalls).toEqual([])
|
||||||
expect(yield* SessionInput.find(db, completion.id)).toMatchObject({ type: "synthetic" })
|
expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
|
||||||
|
|
||||||
yield* session.revert.commit(sessionID)
|
yield* session.revert.commit(sessionID)
|
||||||
|
|
||||||
expect(yield* SessionInput.find(db, completion.id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -433,7 +433,7 @@ describe("SessionV2.prompt", () => {
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, text: "First", resume: false })
|
yield* session.prompt({ sessionID, text: "First", resume: false })
|
||||||
yield* session.prompt({ sessionID, text: "Second", resume: false })
|
yield* session.prompt({ sessionID, text: "Second", resume: false })
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||||
|
|
||||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
||||||
|
|
@ -610,12 +610,12 @@ describe("SessionV2.prompt", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.all(
|
yield* Effect.all(
|
||||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
[SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
|
||||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
expect(yield* admitted(messageID)).toBeUndefined()
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{ id: messageID, type: "user", text: "Promote once" },
|
{ id: messageID, type: "user", text: "Promote once" },
|
||||||
])
|
])
|
||||||
|
|
@ -645,7 +645,11 @@ describe("SessionV2.prompt", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
yield* events.remove(sessionID)
|
yield* events.remove(sessionID)
|
||||||
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
yield* db
|
||||||
|
.delete(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.delete(SessionMessageTable)
|
.delete(SessionMessageTable)
|
||||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||||
|
|
@ -836,7 +840,7 @@ describe("SessionV2.prompt", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{
|
{
|
||||||
|
|
@ -861,14 +865,14 @@ describe("SessionV2.prompt", () => {
|
||||||
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
|
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(database.db, events, sessionID)
|
yield* SessionPending.promoteSteers(database.db, events, sessionID)
|
||||||
const promotedRetry = yield* session.synthetic(input)
|
const promotedRetry = yield* session.synthetic(input)
|
||||||
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
|
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(entries[1]).toEqual(entries[0])
|
expect(entries[1]).toEqual(entries[0])
|
||||||
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", promotedSeq: expect.any(Number) })
|
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
|
||||||
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
|
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
|
||||||
expect(yield* admittedCount).toBe(1)
|
expect(yield* admittedCount).toBe(0)
|
||||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -888,9 +892,9 @@ describe("SessionV2.prompt", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(input.delivery).toBe("queue")
|
expect(input.delivery).toBe("queue")
|
||||||
expect(yield* SessionInput.promoteSteers(db, events, sessionID)).toBe(0)
|
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0)
|
||||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||||
expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true)
|
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
||||||
])
|
])
|
||||||
|
|
@ -916,7 +920,7 @@ describe("SessionV2.prompt", () => {
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
||||||
|
|
@ -926,3 +930,58 @@ describe("SessionV2.prompt", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("SessionV2.pending", () => {
|
||||||
|
it.effect("fails for an unknown session", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
expect(yield* session.pending(SessionV2.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
|
||||||
|
_tag: "Session.NotFoundError",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists admitted work in admission order until promotion", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
|
const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
|
||||||
|
const queued = yield* session.synthetic({
|
||||||
|
sessionID,
|
||||||
|
text: "Queued completion",
|
||||||
|
delivery: "queue",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
|
||||||
|
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||||
|
{ id: first.id, type: "user", delivery: "steer" },
|
||||||
|
{ id: queued.id, type: "synthetic", delivery: "queue" },
|
||||||
|
{ id: second.id, type: "user", delivery: "steer" },
|
||||||
|
])
|
||||||
|
|
||||||
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
|
||||||
|
|
||||||
|
yield* SessionPending.promoteNextQueued(db, events, sessionID)
|
||||||
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists an unhandled compaction barrier until it settles", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
|
const barrier = yield* session.compact({ sessionID })
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
|
||||||
|
|
||||||
|
yield* SessionPending.settleCompaction(db, { sessionID })
|
||||||
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||||
.all()).map((event) => event.type),
|
.all()).map((event) => event.type),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"session.input.admitted.1",
|
"session.input.admitted.1",
|
||||||
|
"session.instructions.updated.2",
|
||||||
"session.input.promoted.1",
|
"session.input.promoted.1",
|
||||||
"session.step.started.1",
|
"session.step.started.1",
|
||||||
"session.text.started.1",
|
"session.text.started.1",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { LLMEvent } from "@opencode-ai/llm"
|
import { LLMEvent } from "@opencode-ai/llm"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
|
@ -16,7 +16,7 @@ const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||||
|
|
||||||
const capture = (providerMetadataKey = "anthropic") => {
|
const capture = (providerMetadataKey = "anthropic") => {
|
||||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||||
const events = EventV2.Service.of({
|
const events: Pick<EventV2.Interface, "publish"> = {
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
|
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
|
||||||
|
|
@ -28,16 +28,7 @@ const capture = (providerMetadataKey = "anthropic") => {
|
||||||
})
|
})
|
||||||
return event
|
return event
|
||||||
}),
|
}),
|
||||||
subscribe: () => Stream.empty,
|
}
|
||||||
log: () => Stream.empty,
|
|
||||||
sequences: () => Effect.succeed(new Map()),
|
|
||||||
listen: () => Effect.succeed(Effect.void),
|
|
||||||
project: () => Effect.void,
|
|
||||||
replay: () => Effect.void,
|
|
||||||
replayAll: () => Effect.succeed(undefined),
|
|
||||||
remove: () => Effect.void,
|
|
||||||
claim: () => Effect.void,
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
published,
|
published,
|
||||||
publisher: createLLMEventPublisher(events, {
|
publisher: createLLMEventPublisher(events, {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
|
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
|
@ -52,6 +52,15 @@ const make = (permission?: string) => {
|
||||||
return permission ? Tool.withPermission(tool, permission) : tool
|
return permission ? Tool.withPermission(tool, permission) : tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const constant = (text: string) =>
|
||||||
|
Tool.make({
|
||||||
|
description: "Return text",
|
||||||
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
|
execute: () => Effect.succeed({ text }),
|
||||||
|
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
|
||||||
|
})
|
||||||
|
|
||||||
describe("ToolRegistry", () => {
|
describe("ToolRegistry", () => {
|
||||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -82,30 +91,6 @@ describe("ToolRegistry", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("materializes all permission-eligible edit tools before request policy", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const service = yield* ToolRegistry.Service
|
|
||||||
yield* service.register({
|
|
||||||
read: make(),
|
|
||||||
edit: make("edit"),
|
|
||||||
write: make("edit"),
|
|
||||||
patch: make("edit"),
|
|
||||||
})
|
|
||||||
const names = (model: ToolRegistry.MaterializeInput["model"]) =>
|
|
||||||
service
|
|
||||||
.materialize({ model })
|
|
||||||
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
|
|
||||||
|
|
||||||
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
|
|
||||||
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
|
|
||||||
"read",
|
|
||||||
"edit",
|
|
||||||
"write",
|
|
||||||
"patch",
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps permission decoration isolated between registrations", () =>
|
it.effect("keeps permission decoration isolated between registrations", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
|
|
@ -122,7 +107,7 @@ describe("ToolRegistry", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reuses model definitions across provider turns", () =>
|
it.effect("reuses model definitions across requests", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({ echo: make() })
|
yield* service.register({ echo: make() })
|
||||||
|
|
@ -201,7 +186,7 @@ describe("ToolRegistry", () => {
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
yield* service.materialize({ model: testModel }).pipe(
|
yield* service.materialize().pipe(
|
||||||
Effect.flatMap((materialized) =>
|
Effect.flatMap((materialized) =>
|
||||||
materialized.settle({
|
materialized.settle({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -219,7 +204,7 @@ describe("ToolRegistry", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({ echo: make() })
|
yield* service.register({ echo: make() })
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
const materialized = yield* service.materialize()
|
||||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
|
@ -345,88 +330,77 @@ describe("ToolRegistry", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("executes the unchanged registration advertised for a provider turn", () =>
|
it.effect("executes the tool advertised in a model request", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
const service = yield* ToolRegistry.Service
|
|
||||||
yield* service.register({ echo: make() })
|
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
|
||||||
|
|
||||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects a call when its advertised registration was removed", () =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
const request = yield* service.materialize()
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
yield* service.register({ echo: constant("replacement") })
|
||||||
|
|
||||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
|
||||||
type: "error",
|
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
|
||||||
value: "Stale tool call: echo",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
|
it.effect("reveals the previous registration after an overlay closes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({ first: make(), second: make() })
|
yield* service.register({ echo: constant("base") })
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
|
||||||
yield* service.register({ first: make() })
|
|
||||||
|
|
||||||
expect((yield* materialized.settle(call("first"))).result).toEqual({
|
|
||||||
type: "error",
|
|
||||||
value: "Stale tool call: first",
|
|
||||||
})
|
|
||||||
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("treats revealing a previous overlay as stale", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const service = yield* ToolRegistry.Service
|
|
||||||
yield* service.register({ echo: make() })
|
|
||||||
const overlay = yield* Scope.make()
|
const overlay = yield* Scope.make()
|
||||||
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
|
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
|
||||||
yield* Scope.close(overlay, Exit.void)
|
|
||||||
|
|
||||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
|
||||||
type: "error",
|
yield* Scope.close(overlay, Exit.void)
|
||||||
value: "Stale tool call: echo",
|
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps captured execution running after registration mutation", () =>
|
it.effect("executes deferred tools advertised in a model request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
const started = yield* Deferred.make<void>()
|
const executed: string[] = []
|
||||||
const release = yield* Deferred.make<void>()
|
|
||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
yield* service
|
yield* service
|
||||||
.register({
|
.register(
|
||||||
|
{
|
||||||
|
echo: Tool.make({
|
||||||
|
description: "Echo text",
|
||||||
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
|
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ deferred: true },
|
||||||
|
)
|
||||||
|
.pipe(Scope.provide(scope))
|
||||||
|
const materialized = yield* service.materialize()
|
||||||
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
yield* service.register(
|
||||||
|
{
|
||||||
echo: Tool.make({
|
echo: Tool.make({
|
||||||
description: "Echo text",
|
description: "Echo text",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) =>
|
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
}),
|
}),
|
||||||
})
|
},
|
||||||
.pipe(Scope.provide(scope))
|
{ deferred: true },
|
||||||
const materialized = yield* service.materialize({ model: testModel })
|
)
|
||||||
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(started)
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
|
||||||
yield* service.register({ echo: make() })
|
|
||||||
yield* Deferred.succeed(release, undefined)
|
|
||||||
|
|
||||||
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
|
const settlement = yield* materialized.settle({
|
||||||
|
...call("execute"),
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-execute",
|
||||||
|
name: "execute",
|
||||||
|
input: { code: 'return await tools.echo({ text: "request" })' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(settlement.result).toMatchObject({ type: "text" })
|
||||||
|
expect(executed).toEqual(["old:request"])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue