diff --git a/AGENTS.md b/AGENTS.md index 703bae8912..8c72fe65a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,9 +155,10 @@ const table = sqliteTable("session", { - 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. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -- Preserve one explicit `llm.stream(request)` call per provider turn 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 step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. -- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn 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 provider-turn 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. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry. - The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline. diff --git a/CONTEXT.md b/CONTEXT.md index 5e5955d344..712e91bf10 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,7 +9,7 @@ The structured collection of contextual facts presented to the model as initial _Avoid_: System prompt **Session History**: -The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs. +The projected chronological conversation selected for a **Step** after applying the active compaction and **Context Epoch** cutoffs. _Avoid_: Session Context **Context Source**: @@ -31,13 +31,13 @@ The full **System Context** rendered at the start of a **Context Epoch**. _Avoid_: Live system prompt **Context Snapshot**: -The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn. +The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a **Step**. **Unavailable Context**: An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. -**Safe Provider-Turn Boundary**: -The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Safe Step Boundary**: +The point immediately before a provider request, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. **Admitted Prompt**: A durable user input accepted into the Session inbox but not yet included in **Session History**. @@ -45,11 +45,24 @@ A durable user input accepted into the Session inbox but not yet included in **S **Prompt Promotion**: The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. -**Provider Turn**: -One request to a model provider and the response projected from that request. +**Step**: +One logical LLM call spanning pre-flight context checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement. +_Avoid_: provider turn, turn (unqualified) + +**Physical Attempt**: +One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two. + +**Assistant Turn**: +A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it. + +**Settlement**: +The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed. + +**Execution**: +One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity. **Session Drain**: -One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. +One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -89,23 +102,25 @@ _Avoid_: Response envelope - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. - **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state. -- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**. +- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Step Boundary**. - A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. - A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. - The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**. - A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key. - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. -- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. -- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- Context changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes. +- At a **Safe Step Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. - An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. - **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. -- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once. - A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. -- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. -- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity. +- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires. +- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record. +- The first **Step** renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the Step instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. -- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**. +- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Step Boundary**. - **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. @@ -113,26 +128,26 @@ _Avoid_: Response envelope - `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. -- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. +- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Step Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. -- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. +- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Step Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. 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 provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. +- 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. - Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. -- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. -- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. -- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. +- 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. +- Context source changes never wake idle sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily. +- Once admitted, a **Mid-Conversation System Message** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. -- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. -- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. -- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns 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. +- Completed compaction starts a new **Context Epoch** on the next **Physical Attempt**, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. +- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next **Step**. +- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. diff --git a/bun.lock b/bun.lock index be72ef8802..6522800a13 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@ast-grep/cli": "0.44.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -126,6 +127,7 @@ "@opencode-ai/schema": "workspace:*", }, "devDependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/httpapi-codegen": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -597,7 +599,6 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/client": "workspace:*", - "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,6 +796,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -848,6 +850,21 @@ "vite": "catalog:", }, }, + "packages/simulation": { + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.17.13", @@ -962,6 +979,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", @@ -1236,6 +1254,22 @@ "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@ast-grep/cli": ["@ast-grep/cli@0.44.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.0", "@ast-grep/cli-darwin-x64": "0.44.0", "@ast-grep/cli-linux-arm64-gnu": "0.44.0", "@ast-grep/cli-linux-x64-gnu": "0.44.0", "@ast-grep/cli-win32-arm64-msvc": "0.44.0", "@ast-grep/cli-win32-ia32-msvc": "0.44.0", "@ast-grep/cli-win32-x64-msvc": "0.44.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-Jf4PuP7XjzsMa3m9gYxmzV8KyWZc4w1ZzKe/t0+90wWxmSasQJe6AtMkJxHEi98MGgfAF1nWziqjDd0/6EsBjA=="], + + "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bF7euu/hF/cYg4510z8110vh60rrqfrBdsfRqVGd6xqNSPENu7CJnTVN/Z4Nk5U1NM8YKzUD+dYx1ySUJ0CUNQ=="], + + "@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0fI9caQGp1dFcmBATNlVytIRdAeYb91v1D2xjMIi1bSX+l8Uj846JUiaimUGBuBZmyFq+BScoWM4RnprEmZMpQ=="], + + "@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JB6EUnqEtGGtyg1GqNquld/++1CvaWD7r84IwwhddX1qx0NmDoHyn2mKd8vnQ24Z0RkV3g7y7foMLakELbGtDw=="], + + "@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rNL0LsI682D9EMzfaGVEtZa1xaqTtGb2I+Zk4ZzidX6u+fF7f79wdqyKahKjXzoIrGkuhkoL3gcyLKAtQd9+qg=="], + + "@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lqD0MhGQAddh2YoV/brKQ6GVcFLmRiTBwIElutwedaUvRCdasTGukFPYuSWk/iI8Kv19xom6s7l+mGuZ7v+xwQ=="], + + "@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZJrnS+2OkNfwyr6yrN69glP67uybBxDvl9mqZvh1J44vB3OFn9U9c+cVAoZIAo7JD5F4rZNxwyu3gcy4+xuwEA=="], + + "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OJEo7f95YYaSuS1byUB7ZctbzxoA7/wCoAol+pt6pvfdW/8Wq+L1qU28glwx7dQ0HgTsnPZbWpXQwmZpCBHhZg=="], + "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], @@ -2002,6 +2036,8 @@ "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], @@ -3490,7 +3526,7 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], @@ -6048,6 +6084,8 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6114,8 +6152,6 @@ "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -6328,8 +6364,6 @@ "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], - "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], @@ -6360,8 +6394,6 @@ "node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -6428,8 +6460,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], @@ -7078,8 +7108,6 @@ "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/package.json b/package.json index 226a86ff2c..9da29ab6ba 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", + "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src", + "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", @@ -95,6 +97,7 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@ast-grep/cli": "0.44.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 82d273c25d..de4d1a2b87 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -4,7 +4,6 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" import { ServiceConfig } from "../../services/service-config" -import type { Transport } from "@opencode-ai/client/effect" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: Transport, + transport: Service.Transport, input: readonly string[], params: Record, ) { diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index ee241bcc1b..1dbaf4d801 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 98b0c9b3c9..d7ba88a824 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,9 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect, Option } from "effect" +import { Effect, Option, Redacted } from "effect" import { Service } from "@opencode-ai/client/effect" -import type { Transport } from "@opencode-ai/client/effect" +import { Env } from "../../env" import { ServiceConfig } from "../../services/service-config" import { Standalone } from "../../services/standalone" import { Updater } from "../../services/updater" @@ -19,11 +19,29 @@ export default Runtime.handler(Commands, (input) => return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) const transport = yield* Effect.gen(function* () { if (server !== undefined) { - const password = process.env["OPENCODE_SERVER_PASSWORD"] - return { + const password = yield* Env.password + const explicit = { url: server, - headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined, - } satisfies Transport + headers: password + ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } + : undefined, + } satisfies Service.Transport + // Fail loudly before entering the TUI: an explicit server that is + // unreachable or rejects auth should not present as reconnect churn. + const response = yield* Effect.tryPromise(() => + fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }), + ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause }))) + if (response.status === 401) + return yield* Effect.fail( + new Error( + password + ? `Server at ${server} rejected the password` + : `Server at ${server} requires a password; set OPENCODE_PASSWORD`, + ), + ) + if (!response.ok) + return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`)) + return explicit } if (input.standalone) return yield* Standalone.transport() const options = yield* ServiceConfig.options() @@ -45,6 +63,24 @@ export default Runtime.handler(Commands, (input) => }).pipe(Effect.provide(NodeFileSystem.layer)), ) : () => Promise.resolve(transport) - yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover) + // Restart the managed service in place; start() resolves once the + // replacement is healthy and the reconnect loop reattaches on its own. + // Only meaningful in service mode: --server is not ours to restart and a + // standalone child cannot be respawned. + const reload = serviceOptions + ? () => + Effect.runPromise( + Effect.gen(function* () { + yield* Service.stop(serviceOptions) + yield* Service.start(serviceOptions) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + : undefined + yield* runTui( + transport, + { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + discover, + reload, + ) }), ) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 167509fb70..2d38a6aca0 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -1,5 +1,5 @@ import { EOL } from "node:os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/migrate.ts b/packages/cli/src/commands/handlers/migrate.ts index c73c7750df..6ff6939aa1 100644 --- a/packages/cli/src/commands/handlers/migrate.ts +++ b/packages/cli/src/commands/handlers/migrate.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 80cf6cba9f..8d263880c5 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -3,9 +3,9 @@ import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Project } from "@opencode-ai/core/project" import { Global } from "@opencode-ai/core/global" -import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect" -import * as Effect from "effect/Effect" +import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { createServer } from "node:http" import { createRoutes } from "@opencode-ai/server/routes" @@ -14,6 +14,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" +import { Service } from "@opencode-ai/client/effect" +import { Env } from "../../env" import { ServiceConfig } from "../../services/service-config" import { Updater } from "../../services/updater" import { randomBytes, randomUUID } from "crypto" @@ -25,12 +27,19 @@ export default Runtime.handler( if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD - if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD + const standalonePassword = yield* Env.password + // Keep the lease credential out of the environment inherited by any + // process this server spawns. + if (input.stdio) { + delete process.env.OPENCODE_PASSWORD + delete process.env.OPENCODE_SERVER_PASSWORD + } const config = input.service ? yield* ServiceConfig.read() : {} const password = input.service ? yield* ServiceConfig.password() - : standalonePassword || randomBytes(32).toString("base64url") + : standalonePassword + ? Redacted.value(standalonePassword) + : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" const port = Option.isSome(input.port) @@ -51,7 +60,7 @@ export default Runtime.handler( if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) - return yield* (input.stdio ? waitForStdinClose() : Effect.never) + return yield* input.stdio ? waitForStdinClose() : Effect.never }).pipe(Effect.annotateLogs({ role: "server" })), ) }), @@ -63,8 +72,11 @@ export default Runtime.handler( // not a startup lock: the atomic rename elects the latest writer, the watcher // self-evicts losers, and the finalizer id-guard keeps an exiting server from // deleting its successor's registration. -const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) }) -const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId)) +// Written and read through Service.Info so the file the server registers is +// provably the contract clients discover with. +const infoJson = Schema.fromJsonString(Service.Info) +const encodeInfo = Schema.encodeEffect(infoJson) +const decodeInfo = Schema.decodeUnknownEffect(infoJson) const register = Effect.fnUntraced(function* (address: HttpServer.Address) { const fs = yield* FileSystem.FileSystem @@ -73,20 +85,17 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address) { const secret = yield* ServiceConfig.password() const temp = file + "." + id + ".tmp" yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString( - temp, - JSON.stringify({ - id, - version: InstallationVersion, - url: HttpServer.formatAddress(address), - pid: process.pid, - password: secret, - }), - { mode: 0o600 }, - ) + const encoded = yield* encodeInfo({ + id, + version: InstallationVersion, + url: HttpServer.formatAddress(address), + pid: process.pid, + password: secret, + }) + yield* fs.writeFileString(temp, encoded, { mode: 0o600 }) yield* fs.rename(temp, file) const currentID = fs.readFileString(file).pipe( - Effect.flatMap(decodeRegistrationId), + Effect.flatMap(decodeInfo), Effect.map((info) => info.id), Effect.orElseSucceed(() => undefined), ) @@ -134,9 +143,9 @@ function listen(hostname: string, port: Option.Option, password: string) function bind(hostname: string, port: number, password: string) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( + HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))), + Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), diff --git a/packages/cli/src/commands/handlers/service/get.ts b/packages/cli/src/commands/handlers/service/get.ts index 1b85faf6e9..fd4b9af384 100644 --- a/packages/cli/src/commands/handlers/service/get.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -1,6 +1,5 @@ import { EOL } from "os" -import { Option } from "effect" -import * as Effect from "effect/Effect" +import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index c78273ed0f..93b8836acd 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts index 6ecde18d41..f761c02411 100644 --- a/packages/cli/src/commands/handlers/service/set.ts +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index a3a7200bf9..602a26ecf1 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index 807d7d749d..bf58968eef 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts index 53ad3615c3..5bf45ccabc 100644 --- a/packages/cli/src/commands/handlers/service/stop.ts +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts index ef5fdf9330..cc738125d3 100644 --- a/packages/cli/src/commands/handlers/service/unset.ts +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts new file mode 100644 index 0000000000..6cc76b793f --- /dev/null +++ b/packages/cli/src/env.ts @@ -0,0 +1,15 @@ +import { Config } from "effect" + +// Every environment variable the CLI reads, in one place. Consumers yield +// these instead of touching process.env so the full surface stays visible, +// typed, and redacted where secret. + +// The opencode server password: sent by clients connecting to an explicit +// --server, and adopted by a manually run or standalone server. The legacy +// name is still honored. +export const password = Config.redacted("OPENCODE_PASSWORD").pipe( + Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")), + Config.withDefault(undefined), +) + +export * as Env from "./env" diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 902c89d716..2108da415d 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,9 +1,8 @@ -import * as Effect from "effect/Effect" -import * as Command from "effect/unstable/cli/Command" +import { Effect, FileSystem, Scope } from "effect" +import { Command } from "effect/unstable/cli" import { Spec } from "./spec" import { Global } from "@opencode-ai/core/global" import { Updater } from "../services/updater" -import { FileSystem, Scope } from "effect" export type Input = Value extends Spec.Node @@ -12,11 +11,21 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = ( + input: unknown, +) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: ( + input: Input, + ) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command< + string, + unknown, + unknown, + unknown, + FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope +> export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/framework/spec.ts b/packages/cli/src/framework/spec.ts index 3bb47e5e5e..345a0cfa85 100644 --- a/packages/cli/src/framework/spec.ts +++ b/packages/cli/src/framework/spec.ts @@ -1,4 +1,4 @@ -import * as Command from "effect/unstable/cli/Command" +import { Command } from "effect/unstable/cli" type Options> = { readonly description?: string diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 071fd37e1a..64116b9b76 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,7 @@ #!/usr/bin/env bun -import * as NodeRuntime from "@effect/platform-node/NodeRuntime" -import * as NodeServices from "@effect/platform-node/NodeServices" -import { NodeFileSystem } from "@effect/platform-node" -import * as Effect from "effect/Effect" -import { Layer, Logger, References } from "effect" +import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node" +import { Effect, Layer, Logger, References } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Logging } from "@opencode-ai/core/observability/logging" @@ -46,7 +43,12 @@ const Handlers = Runtime.handlers(Commands, { serve: () => import("./commands/handlers/serve"), }) -Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( +Effect.logInfo("cli starting", { + version: InstallationVersion, + channel: InstallationChannel, + local: InstallationLocal, + args: process.argv.slice(2), +}).pipe( Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.annotateLogs({ role: "cli" }), Effect.provide(Updater.layer), diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 356472e359..ecdd5bf19a 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -5,7 +5,7 @@ import { Effect, FileSystem, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" -// The CLI's service configuration file, plus the ServiceOptions binding that +// The CLI's service configuration file, plus the Service.Options binding that // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index b591a5deb1..285a433b55 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -15,7 +15,9 @@ function command(password: string) { if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { cwd: process.cwd(), - env: { OPENCODE_SERVER_PASSWORD: password }, + // Explicit entry wins over anything inherited, so a user-exported + // OPENCODE_PASSWORD cannot shadow the child's lease credential. + env: { OPENCODE_PASSWORD: password }, extendEnv: true, // The server treats EOF on this pipe as the end of its ownership lease. // The OS closes it even when the TUI is killed before Effect finalizers run. diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 577a7be493..96d92c50b2 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -5,11 +5,16 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { OpenCode } from "@opencode-ai/client/promise" -import type { Transport } from "@opencode-ai/client/effect" +import type { Service } from "@opencode-ai/client/effect" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Args } from "@opencode-ai/tui/context/args" -export function runTui(transport: Transport, args: Args, discover?: () => Promise) { +export function runTui( + transport: Service.Transport, + args: Args, + discover?: () => Promise, + reload?: () => Promise, +) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { @@ -33,6 +38,7 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis } } : undefined, + reload, args, config, pluginHost: { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index f25557c0de..a9a91eb8c6 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] +} const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.shell"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], command: input["command"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I Effect.map((value) => value.data), ) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly key: Endpoint4_20Request["params"]["key"] +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly key: Endpoint4_21Request["params"]["key"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I ), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ command: Endpoint4_9(raw), skill: Endpoint4_10(raw), synthetic: Endpoint4_11(raw), - compact: Endpoint4_12(raw), - wait: Endpoint4_13(raw), - revertStage: Endpoint4_14(raw), - revertClear: Endpoint4_15(raw), - revertCommit: Endpoint4_16(raw), - context: Endpoint4_17(raw), - listContextEntries: Endpoint4_18(raw), - putContextEntry: Endpoint4_19(raw), - removeContextEntry: Endpoint4_20(raw), - log: Endpoint4_21(raw), - interrupt: Endpoint4_22(raw), - background: Endpoint4_23(raw), - message: Endpoint4_24(raw), + shell: Endpoint4_12(raw), + compact: Endpoint4_13(raw), + wait: Endpoint4_14(raw), + revertStage: Endpoint4_15(raw), + revertClear: Endpoint4_16(raw), + revertCommit: Endpoint4_17(raw), + context: Endpoint4_18(raw), + listContextEntries: Endpoint4_19(raw), + putContextEntry: Endpoint4_20(raw), + removeContextEntry: Endpoint4_21(raw), + log: Endpoint4_22(raw), + interrupt: Endpoint4_23(raw), + background: Endpoint4_24(raw), + message: Endpoint4_25(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 1a69a358cc..23e6d55c74 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -2,7 +2,6 @@ // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. export * from "./generated/index" export { Service } from "./service.js" -export type { Transport, ServiceOptions } from "./service.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Credential } from "@opencode-ai/schema/credential" diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 93cbdd03fc..ca428f5aa6 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -16,7 +16,7 @@ export type Transport = { readonly headers?: RequestInit["headers"] } -export type ServiceOptions = { +export type Options = { // Absolute path to the service registration file. Defaults to // opencode/service.json in the XDG state directory. readonly file?: string @@ -30,21 +30,21 @@ export type ServiceOptions = { // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. -export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - if (options.version !== undefined && registration.version !== options.version) return undefined - const found = yield* probe(registration) +export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { + const info = yield* read(options.file) + if (info === undefined) return undefined + if (options.version !== undefined && info.version !== options.version) return undefined + const found = yield* probe(info) return found?.transport }) // Idempotent ensure-running: reuses a healthy compatible server, replaces a // version-mismatched one, and otherwise spawns the service command detached. -export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) { +export const start = Effect.fn("service.start")(function* (options: Options = {}) { const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) - if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore) + if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) @@ -64,10 +64,10 @@ export const start = Effect.fn("service.start")(function* (options: ServiceOptio ) }) -export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) { +export const stop = Effect.fn("service.stop")(function* (options: Options = {}) { const fs = yield* FileSystem.FileSystem const existing = yield* find(options) - if (existing !== undefined) yield* kill(existing.registration, options) + if (existing !== undefined) yield* kill(existing.info, options) yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) }) @@ -80,18 +80,18 @@ function auth(password: string): RequestInit["headers"] { return { authorization: "Basic " + btoa("opencode:" + password) } } -const Registration = Schema.Struct({ +export const Info = Schema.Struct({ id: Schema.optional(Schema.String), version: Schema.optional(Schema.String), url: Schema.String, pid: Schema.Int.check(Schema.isGreaterThan(0)), password: Schema.optional(Schema.String), }) -type Registration = typeof Registration.Type +export type Info = typeof Info.Type -const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) -// A missing or corrupt file means no valid registration; callers treat both +// A missing or corrupt file means no valid info; callers treat both // the same (the registering server self-evicts, clients rediscover). const read = Effect.fnUntraced(function* (file?: string) { const fs = yield* FileSystem.FileSystem @@ -101,14 +101,14 @@ const read = Effect.fnUntraced(function* (file?: string) { }) type LocalService = { - readonly registration: Registration + readonly info: Info readonly transport: Transport } -const probe = Effect.fnUntraced(function* (registration: Registration) { - const headers = registration.password === undefined ? undefined : auth(registration.password) +const probe = Effect.fnUntraced(function* (info: Info) { + const headers = info.password === undefined ? undefined : auth(info.password) const healthy = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", registration.url), { + fetch(new URL("/api/health", info.url), { headers, signal: AbortSignal.timeout(2_000), }), @@ -117,15 +117,15 @@ const probe = Effect.fnUntraced(function* (registration: Registration) { Effect.orElseSucceed(() => false), ) if (!healthy) return undefined - return { registration, transport: { url: registration.url, headers } } satisfies LocalService + return { info, transport: { url: info.url, headers } } satisfies LocalService }) // Health-checked lookup without the version gate: lifecycle operations must be // able to see (and replace or stop) a server from a different version. -const find = Effect.fnUntraced(function* (options: ServiceOptions) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - return yield* probe(registration) +const find = Effect.fnUntraced(function* (options: Options) { + const info = yield* read(options.file) + if (info === undefined) return undefined + return yield* probe(info) }) // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. @@ -142,22 +142,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) { return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) }) -function same(left: Registration, right: Registration) { +function same(left: Info, right: Info) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid } -const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) { +const kill = Effect.fnUntraced(function* (info: Info, options: Options) { // A stale registration may point at a PID that has since been reused by // another process. Only signal the PID after authenticating the server. const current = yield* find(options) - if (current === undefined || !same(current.registration, info)) return + if (current === undefined || !same(current.info, info)) return yield* signal(info.pid, "SIGTERM") const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option) if (Option.isSome(done)) return const latest = yield* find(options) - if (latest === undefined || !same(latest.registration, info)) return + if (latest === undefined || !same(latest.info, info)) return yield* signal(info.pid, "SIGKILL") yield* stopped(info.pid).pipe(Effect.retry(poll)) }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index ee752f23ef..79368764c7 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -29,6 +29,8 @@ import type { SessionSkillOutput, SessionSyntheticInput, SessionSyntheticOutput, + SessionShellInput, + SessionShellOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -525,6 +527,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + shell: (input: SessionShellInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`, + body: { id: input["id"], command: input["command"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d858ded933..13dab421d5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -865,6 +865,14 @@ export type SessionSyntheticInput = { export type SessionSyntheticOutput = void +export type SessionShellInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { readonly id?: string | undefined; readonly command: string }["id"] + readonly command: { readonly id?: string | undefined; readonly command: string }["command"] +} + +export type SessionShellOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void @@ -1099,74 +1107,75 @@ export type SessionLogOutput = | ( | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.agent.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "agent.selected" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly agent: string - } + readonly data: { readonly sessionID: string; readonly agent: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.model.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "model.selected" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.moved" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.moved" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } - readonly subdirectory?: string + readonly subpath?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.renamed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "renamed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string } + readonly data: { readonly sessionID: string; readonly title: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.forked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "forked" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly parentID: string - readonly messageID?: string - } + readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "prompt.promoted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly inputID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "prompt.admitted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string + readonly inputID: string readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ @@ -1186,54 +1195,22 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompt.admitted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.context.updated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string - readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.context.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "synthetic" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string - readonly text: string - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.synthetic" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string readonly text: string readonly description?: string readonly metadata?: { readonly [x: string]: unknown } @@ -1241,53 +1218,39 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.skill.activated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "skill.activated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly name: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.shell.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "shell.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly callID: string - readonly command: string - } + readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.shell.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "shell.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly callID: string - readonly output: string - } + readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly agent: string @@ -1297,12 +1260,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly finish: string @@ -1319,12 +1282,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: "unknown"; readonly message: string } @@ -1332,25 +1295,21 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "text.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly textID: string - } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "text.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -1359,12 +1318,41 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "reasoning.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "reasoning.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "tool.input.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1373,12 +1361,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.input.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1387,12 +1375,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.called" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.called" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1406,12 +1394,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.progress" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.progress" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1424,12 +1412,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.success" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.success" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1448,12 +1436,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1467,41 +1455,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "retried" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.retried" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly attempt: number readonly error: { @@ -1516,27 +1475,22 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "compaction.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly reason: "auto" | "manual" - } + readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "compaction.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly reason: "auto" | "manual" readonly text: string readonly recent: string @@ -1544,12 +1498,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.staged" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.staged" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly revert: { readonly messageID: string @@ -1568,19 +1522,21 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.cleared" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.cleared" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string } + readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.committed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.committed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly messageID: string } } ) | { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number } @@ -3763,49 +3719,50 @@ export type SkillListOutput = { export type EventSubscribeOutput = | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "models-dev.refreshed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "integration.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "integration.connection.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly integrationID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "catalog.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "agent.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3864,9 +3821,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3925,9 +3883,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3986,9 +3945,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4090,17 +4050,19 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.removed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4336,82 +4298,84 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.removed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.agent.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "agent.selected" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly agent: string - } + readonly data: { readonly sessionID: string; readonly agent: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.model.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "model.selected" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.moved" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.moved" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } - readonly subdirectory?: string + readonly subpath?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.renamed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "renamed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string } + readonly data: { readonly sessionID: string; readonly title: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.forked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "forked" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly parentID: string - readonly messageID?: string - } + readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "prompt.promoted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly inputID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "prompt.admitted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string + readonly inputID: string readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ @@ -4431,39 +4395,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompt.admitted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "execution.settled" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string - readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.execution.settled" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly outcome: "success" | "failure" | "interrupted" readonly error?: { readonly type: "unknown"; readonly message: string } @@ -4471,27 +4407,22 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.context.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.context.updated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.synthetic" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "synthetic" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly text: string readonly description?: string readonly metadata?: { readonly [x: string]: unknown } @@ -4499,53 +4430,39 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.skill.activated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "skill.activated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly name: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.shell.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "shell.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly callID: string - readonly command: string - } + readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.shell.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "shell.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly callID: string - readonly output: string - } + readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly agent: string @@ -4555,12 +4472,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly finish: string @@ -4577,12 +4494,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "step.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: "unknown"; readonly message: string } @@ -4590,25 +4507,20 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "text.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly textID: string - } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "text.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -4617,12 +4529,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "text.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -4631,12 +4543,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "reasoning.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4645,12 +4557,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "reasoning.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4659,12 +4570,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "reasoning.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4674,12 +4585,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.input.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4688,12 +4599,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.input.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4702,12 +4612,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.input.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4716,12 +4626,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.called" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.called" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4735,12 +4645,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.progress" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.progress" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4753,12 +4663,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.success" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.success" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4777,12 +4687,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "tool.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4796,12 +4706,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.retried" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "retried" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly attempt: number readonly error: { @@ -4816,40 +4726,30 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "compaction.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly reason: "auto" | "manual" - } + readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "compaction.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "compaction.ended" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly reason: "auto" | "manual" readonly text: string readonly recent: string @@ -4857,12 +4757,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.staged" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.staged" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly revert: { readonly messageID: string @@ -4881,41 +4781,43 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.cleared" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.cleared" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string } + readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.committed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "revert.committed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly messageID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "file.edited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly file: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "reference.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.v2.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -4929,9 +4831,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.v2.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4941,49 +4843,49 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "plugin.added" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "project.directories.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "command.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "skill.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "file.watcher.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5000,9 +4902,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5019,25 +4921,25 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.exited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string; readonly exitCode: number } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "shell.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5056,9 +4958,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "shell.exited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5068,17 +4970,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "shell.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.v2.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5095,9 +4997,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.v2.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5107,17 +5009,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.v2.rejected" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly requestID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly form: @@ -5230,9 +5132,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5242,17 +5144,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.cancelled" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string; readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "todo.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5261,9 +5163,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.status" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5288,25 +5190,25 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.idle" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.prompt.append" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.command.execute" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly command: @@ -5332,9 +5234,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.toast.show" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly title?: string @@ -5345,49 +5247,49 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.session.select" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "installation.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly version: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "installation.update-available" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly version: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "vcs.branch.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly branch?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "mcp.status.changed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly server: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5401,9 +5303,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5413,9 +5315,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5432,9 +5334,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5444,17 +5346,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.rejected" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly requestID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.error" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID?: string | undefined @@ -5495,7 +5397,6 @@ export type EventSubscribeOutput = | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined readonly location?: { readonly directory: string; readonly workspaceID?: string } | undefined readonly type: "server.connected" readonly data: {} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 61bdcdf888..e9e848b160 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -1,4 +1,3 @@ export * from "./generated/index" -export type { Transport } from "../effect/service.js" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type OpenCodeClient = ReturnType diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 445a9cf3de..5a7ec9b7cc 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,17 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index" +import { + AbsolutePath, + Agent, + Event, + Location, + Model, + OpenCode, + Prompt, + Session, + SessionMessage, +} from "../src/effect/index" const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } @@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async HttpClientResponse.fromWeb( request, new Response( - `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { headers: { "content-type": "text/event-stream" } }, ), @@ -35,10 +45,10 @@ test("event.subscribe exposes and decodes the native Effect event stream", async return yield* client.event.subscribe().pipe(Stream.runCollect) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) - expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"]) const durable = events[1] - if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") - expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + if (durable?.type !== "model.selected") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000) expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) }) @@ -149,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(result.context).toEqual([]) expect(logQueries[0]).toEqual({ after: "0" }) const logged = Array.from(result.log) - expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"]) - expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe( + expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"]) + expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( 1_717_171_717_000, ) expect(logged.at(-1)).toEqual(synced) @@ -217,12 +227,11 @@ const modelSwitchedMessage = { const modelSwitchedEvent = { id: "evt_model", - type: "session.next.model.switched", + created: 1_717_171_717_000, + type: "model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { - timestamp: 1_717_171_717_000, sessionID: "ses_test", - messageID: "msg_model", model: { id: "claude", providerID: "anthropic" }, }, } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 030b761fa6..9e8ea7acfe 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -151,7 +151,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( baseUrl: "http://localhost:3000", fetch: async () => new Response( - `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { headers: { "content-type": "text/event-stream" } }, ), @@ -159,8 +159,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( const events = [] for await (const event of client.event.subscribe()) events.push(event) - expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) - expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) + expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000) }) test("event.subscribe terminates on malformed Promise SSE data", async () => { @@ -328,12 +328,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 } const modelSwitchedEvent = { id: "evt_model", - type: "session.next.model.switched", + created: 1_717_171_717_000, + type: "model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { - timestamp: 1_717_171_717_000, sessionID: "ses_test", - messageID: "msg_model", model: { id: "claude", providerID: "anthropic" }, }, } diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index fa3ddc6c2f..d79d0182b5 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -458,8 +458,8 @@ export const discoveryPlan = ( // Section order is deliberate: workflow first (the top is the least likely part of a long // description to be truncated or skimmed away), then rules, then syntax, with the budgeted - // catalog at the bottom. Example call forms use explicit `.` placeholders - - // never a real or fabricated tool name. + // catalog at the bottom. Example call forms use placeholders - never a real or fabricated + // tool name - and show both dot and bracket notation so non-identifier names are not normalized. const intro = [ "Write a CodeMode program to answer the request. Return code only.", empty @@ -467,6 +467,7 @@ export const discoveryPlan = ( : complete ? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here." : "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.", + ...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), ] // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE @@ -480,14 +481,14 @@ export const discoveryPlan = ( ...(complete ? [ "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", - "2. Call it using the exact signature shown: `const res = await tools..(input)` - bracket notation may appear for names that are not JavaScript identifiers.", + '2. Call it using the exact signature shown; bracket notation and quotes are part of the path.', '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', "4. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", ] : [ - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` - short phrases like "list issues" work best.', + '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', "2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.", - "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)` - bracket notation may appear for names that are not JavaScript identifiers.", + "3. Call the result's `path` as-is; bracket notation and quotes are part of the path.", '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', "5. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", ]), @@ -504,7 +505,7 @@ export const discoveryPlan = ( : "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.", "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", "- A result typed `Promise` has no guaranteed shape - verify what actually came back before relying on its fields.", - "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`.", + '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index eaa834e0a4..4fda88b384 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -622,12 +622,12 @@ describe("CodeMode public contract", () => { ) expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("raw payloads get truncated and waste context") - expect(instructions).toContain("`const res = await tools..(input)`") + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain("bracket notation and quotes are part of the path") expect(instructions).toContain("surrounding agent tools are not available unless listed here") expect(instructions).toContain("Only tools listed here are available inside `tools`") - expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers") - // Placeholders use the ./ style ONLY - no fabricated tool - // names, and no real catalog tools cherry-picked into example lines. + // Placeholders use generic namespace/tool/field names only - no fabricated real tools + // and no real catalog tools cherry-picked into example lines. expect(instructions).toContain("`return { : data. }`") expect(instructions).not.toContain("total_count") expect(instructions).not.toContain("list_issues") @@ -640,7 +640,7 @@ describe("CodeMode public contract", () => { // PARTIAL: the workflow starts with search (with query-style guidance that is clearly // a query string, never a tool name) and the browse-namespace rule appears. expect(partial).toContain( - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` - short phrases like "list issues" work best.', + '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', ) expect(partial).toContain( "Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 9c45371d93..edf4c442c4 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -339,3 +339,43 @@ describe("pretty signatures in search results", () => { expect(instructions).not.toContain("/**") }) }) + +describe("non-identifier tool paths", () => { + const resolveLibrary = Tool.make({ + description: "Resolve a Context7 library ID", + input: { + type: "object", + properties: { + query: { type: "string" }, + libraryName: { type: "string" }, + }, + required: ["query", "libraryName"], + } as const, + run: () => Effect.succeed("/reactjs/react.dev"), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + test("inline catalog uses bracket notation for dashed tool names", () => { + const instructions = runtime.instructions() + + expect(instructions).toContain( + 'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise', + ) + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain("bracket notation and quotes are part of the path") + expect(instructions).not.toContain("tools.context7.resolve-library-id") + expect(instructions).not.toContain("tools.context7.resolve_library_id") + }) + + test("search results return callable bracket-notation paths and signatures", async () => { + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`), + ) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + + const value = result.value as { items: Array<{ path: string; signature: string }> } + expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]') + expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {') + }) +}) diff --git a/packages/core/schema.json b/packages/core/schema.json index e8a2502a34..d9e0b20b22 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", + "id": "96e9fe64-d810-4102-8f79-3317a88bb6d2", "prevIds": [ - "f14a9b18-8207-487e-a3d3-227e629ba9ad" + "22e57fed-b9b8-4e94-a3b4-f94bece680a8" ], "ddl": [ { @@ -526,6 +526,16 @@ "entityType": "columns", "table": "event" }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created", + "entityType": "columns", + "table": "event" + }, { "type": "text", "notNull": true, diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index d364d6f344..7e81780e95 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -1,7 +1,7 @@ export * as AccountV2 from "./account" import { Schema } from "effect" -import type * as HttpClientError from "effect/unstable/http/HttpClientError" +import type { HttpClientError } from "effect/unstable/http" export const ID = Schema.String.pipe(Schema.brand("AccountID")) export type ID = Schema.Schema.Type diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts index 12a02635db..8b38a225b4 100644 --- a/packages/core/src/config/experimental.ts +++ b/packages/core/src/config/experimental.ts @@ -2,17 +2,19 @@ export * as ConfigExperimental from "./experimental" import { Schema } from "effect" import { Catalog } from "../catalog" -import { Policy as PolicyV2 } from "../policy" +import { Policy } from "../policy" // Each core domain exports the policy actions it supports. Adding an action to // this union makes it valid in authored config while keeping Policy generic. export const PolicyAction = Schema.Union([Catalog.PolicyActions]) -export class Policy extends Schema.Class("ConfigV2.Experimental.Policy")({ - ...PolicyV2.Info.fields, +class PolicyConfig extends Schema.Class("ConfigV2.Experimental.Policy")({ + ...Policy.Info.fields, action: PolicyAction, }) {} +export { PolicyConfig as Policy } + export class Experimental extends Schema.Class("ConfigV2.Experimental")({ - policies: Policy.pipe(Schema.Array, Schema.optional), + policies: PolicyConfig.pipe(Schema.Array, Schema.optional), }) {} diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index e227f88df5..6f0c6e3081 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -106,8 +106,7 @@ const layer = Layer.effect( yield* events.publish(SessionEvent.Moved, { sessionID: input.sessionID, location: Location.Ref.make({ directory }), - subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), - timestamp: yield* DateTime.now, + subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), }) if (patch) { diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 6ea9022acf..f6a04ffd93 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -1,26 +1,17 @@ -import type * as Arr from "effect/Array" -import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" -import * as NodePath from "@effect/platform-node/NodePath" -import * as Deferred from "effect/Deferred" -import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as FileSystem from "effect/FileSystem" -import * as Layer from "effect/Layer" -import * as Path from "effect/Path" -import * as PlatformError from "effect/PlatformError" -import * as Predicate from "effect/Predicate" -import type * as Scope from "effect/Scope" -import * as Sink from "effect/Sink" -import * as Stream from "effect/Stream" -import * as ChildProcess from "effect/unstable/process/ChildProcess" -import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import type { NonEmptyReadonlyArray } from "effect/Array" +import { NodeFileSystem, NodePath, NodeSink, NodeStream } from "@effect/platform-node" +import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect" +import type { Scope } from "effect" +import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner, ExitCode, - make as makeSpawner, + make, makeHandle, ProcessId, + type ChildProcessHandle, } from "effect/unstable/process/ChildProcessSpawner" +// ast-grep-ignore: no-star-import import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" @@ -71,7 +62,7 @@ const flatten = (command: ChildProcess.Command) => { if (commands.length === 0) throw new Error("flatten produced empty commands array") const [head, ...tail] = commands return { - commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + commands: [head, ...tail] as NonEmptyReadonlyArray, opts, } } @@ -96,7 +87,7 @@ const toPlatformError = ( type ExitSignal = Deferred.Deferred -export const make = Effect.gen(function* () { +const makeCrossSpawnSpawner = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -494,12 +485,12 @@ export const make = Effect.gen(function* () { }, ) - return makeSpawner(spawnCommand) + return make(spawnCommand) }) const layer: Layer.Layer = Layer.effect( ChildProcessSpawner, - make, + makeCrossSpawnSpawner, ) export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] }) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047e..f3cc8b3f9f 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -1,7 +1,7 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { layer as sqliteLayer } from "#sqlite" +import { layer } from "#sqlite" import { Context, Effect, Layer } from "effect" import { Global } from "../global" import { Flag } from "../flag/flag" @@ -19,7 +19,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -const layer = Layer.effect( +const databaseLayer = Layer.effect( Service, Effect.gen(function* () { const db = yield* makeDatabase @@ -37,7 +37,7 @@ const layer = Layer.effect( ) export function layerFromPath(filename: string) { - return layer.pipe(Layer.provide(sqliteLayer({ filename }))) + return databaseLayer.pipe(Layer.provide(layer({ filename }))) } export function path() { diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 55c1b212cd..7956b64f4d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -41,5 +41,7 @@ export const migrations = ( import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), import("./migration/20260702134641_add_session_context_entry"), + import("./migration/20260703090000_reset_v2_event_rename_sweep"), + import("./migration/20260703181610_event_created_column"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 90dee8acbf..17bf0a5c44 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -22,7 +22,7 @@ export function apply(db: Database) { sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, ) if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) - if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") + if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table")) yield* db.transaction((tx) => Effect.gen(function* () { yield* schema.up(tx) diff --git a/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts b/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts new file mode 100644 index 0000000000..20672f0734 --- /dev/null +++ b/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703090000_reset_v2_event_rename_sweep", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + // `created` column is added by the generated 20260703181610_event_created_column + // migration, which runs after this wipe (NOT NULL without default is safe on the + // emptied table). + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260703181610_event_created_column.ts b/packages/core/src/database/migration/20260703181610_event_created_column.ts new file mode 100644 index 0000000000..29d2cf9c12 --- /dev/null +++ b/packages/core/src/database/migration/20260703181610_event_created_column.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703181610_event_created_column", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/path.ts b/packages/core/src/database/path.ts index 379d5f8aa7..93fd2d5773 100644 --- a/packages/core/src/database/path.ts +++ b/packages/core/src/database/path.ts @@ -1,5 +1,6 @@ import nodePath from "path" import { customType } from "drizzle-orm/sqlite-core" +import { Schema } from "effect" import { AbsolutePath } from "../schema" function storagePath(input: string) { @@ -74,6 +75,8 @@ export const pathColumn = customType<{ }, }) +const decodeAbsoluteArray = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String))) + export const absoluteArrayColumn = customType<{ data: AbsolutePath[] driverData: string @@ -86,6 +89,6 @@ export const absoluteArrayColumn = customType<{ return JSON.stringify(input.map(absolute)) }, fromDriver(input) { - return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item)))) + return decodeAbsoluteArray(input).map((item) => AbsolutePath.make(toPlatform(absolute(item)))) }, }) diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index d8f0f1c665..17a7d12aed 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -81,6 +81,7 @@ export default { \`id\` text PRIMARY KEY, \`aggregate_id\` text NOT NULL, \`seq\` integer NOT NULL, + \`created\` integer NOT NULL, \`type\` text NOT NULL, \`data\` text NOT NULL, CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e..bbb3ad6143 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -1,18 +1,11 @@ import { Database } from "bun:sqlite" import { drizzle } from "drizzle-orm/bun-sqlite" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Fiber from "effect/Fiber" +import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { identity } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Scope from "effect/Scope" -import * as Semaphore from "effect/Semaphore" -import * as Stream from "effect/Stream" -import * as Reactivity from "effect/unstable/reactivity/Reactivity" -import * as Client from "effect/unstable/sql/SqlClient" +import { Reactivity } from "effect/unstable/reactivity" +import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" -import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const TypeId = "~@opencode-ai/core/database/SqliteBun" as const type TypeId = typeof TypeId -interface SqliteClient extends Client.SqlClient { +interface SqliteClient extends SqlClient.SqlClient { readonly [TypeId]: TypeId readonly config: Config readonly export: Effect.Effect @@ -57,7 +50,7 @@ const make = (options: Config) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed((statement.all(...(params as any)) ?? []) as Array>) } catch (cause) { @@ -73,7 +66,7 @@ const make = (options: Config) => Effect.withFiber, SqlError>((fiber) => { const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed((statement.values(...(params as any)) ?? []) as Array) } catch (cause) { @@ -130,7 +123,7 @@ const make = (options: Config) => }) const client = Object.assign( - (yield* Client.make({ + (yield* SqlClient.make({ acquirer, compiler, transactionAcquirer, @@ -166,7 +159,7 @@ const nativeLayer = (config: Config) => }), ) -const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) +const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const drizzleLayer = Layer.effect( Sqlite.Drizzle, diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26..6fcbd76e4e 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -1,18 +1,11 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite" import { drizzle } from "drizzle-orm/node-sqlite" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Fiber from "effect/Fiber" +import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { identity } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Scope from "effect/Scope" -import * as Semaphore from "effect/Semaphore" -import * as Stream from "effect/Stream" -import * as Reactivity from "effect/unstable/reactivity/Reactivity" -import * as Client from "effect/unstable/sql/SqlClient" +import { Reactivity } from "effect/unstable/reactivity" +import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" -import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const TypeId = "~@opencode-ai/core/database/SqliteNode" as const type TypeId = typeof TypeId -interface SqliteClient extends Client.SqlClient { +interface SqliteClient extends SqlClient.SqlClient { readonly [TypeId]: TypeId readonly config: Config readonly loadExtension: (path: string) => Effect.Effect @@ -56,7 +49,7 @@ const make = (options: Config) => const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) } catch (cause) { @@ -71,7 +64,7 @@ const make = (options: Config) => const runValues = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers)) statement.setReturnArrays(true) try { return Effect.succeed( @@ -124,7 +117,7 @@ const make = (options: Config) => }) const client = Object.assign( - (yield* Client.make({ + (yield* SqlClient.make({ acquirer, compiler, transactionAcquirer, @@ -161,7 +154,7 @@ const nativeLayer = (config: Config) => }), ) -const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) +const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const drizzleLayer = Layer.effect( Sqlite.Drizzle, diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 9dbc3d5160..dc7aa972f0 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -227,7 +227,7 @@ export function hoist @@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => { } return { id: event.id, + created: event.created ?? DateTime.makeUnsafe(0), type: definition.type, durable: envelope(event.aggregateID, event.seq, definition.durable.version), data: Schema.decodeUnknownSync(definition.data)(event.data), @@ -92,8 +94,9 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass if ( stored?.id === event.id && stored.type === versionedType(definition.type, durable.version) && + stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && isDeepStrictEqual(stored.data, encoded) ) { if (input.ownerID && row?.ownerID == null) { @@ -365,6 +369,7 @@ export const layerWith = (options?: LayerOptions) => id: event.id, aggregate_id: aggregateID, seq, + created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), type: versionedType(definition.type, durable.version), data: encoded, }, @@ -470,6 +475,7 @@ export const layerWith = (options?: LayerOptions) => definition, { id: options?.id ?? ID.create(), + created: yield* DateTime.now, ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, ...(location ? { location } : {}), @@ -493,6 +499,7 @@ export const layerWith = (options?: LayerOptions) => } else { const payload = { id: event.id, + created: event.created ?? DateTime.makeUnsafe(0), type: definition.type, data: Schema.decodeUnknownSync(definition.data)(event.data), } as Payload @@ -609,6 +616,7 @@ export const layerWith = (options?: LayerOptions) => return [ decodeSerializedEvent({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 38fe34f1e3..17c88cefd3 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -15,6 +15,7 @@ export const EventTable = sqliteTable( .notNull() .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), seq: integer().notNull(), + created: integer().notNull(), type: text().notNull(), data: text({ mode: "json" }).$type>().notNull(), }, diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index fe7a5b2594..1efc9d6907 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -113,7 +113,8 @@ const layer = Layer.effect( ) } - const config = (yield* (yield* Config.Service).entries()) + const configService = yield* Config.Service + const config = (yield* configService.entries()) .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) yield* Effect.forkScoped( diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 47a72ed23a..d49129b3b8 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -98,10 +98,14 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const forms = yield* Cache.makeWith(() => Effect.die("Form cache must be used via set/getSuccess, never get"), { - capacity: Number.MAX_SAFE_INTEGER, - timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION), - }) + const forms = yield* Cache.makeWith( + () => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")), + { + capacity: Number.MAX_SAFE_INTEGER, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION, + }, + ) const find = Effect.fn("Form.find")(function* (id: ID) { return yield* Cache.getSuccess(forms, id).pipe( @@ -131,7 +135,9 @@ export const layer = Layer.effect( ...(input.metadata === undefined ? {} : { metadata: input.metadata }), } const form: Info = - input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url } + input.mode === "form" + ? { ...base, mode: "form", fields: input.fields } + : { ...base, mode: "url", url: input.url } const entry: Entry = { form, state: { status: "pending" }, @@ -149,7 +155,9 @@ export const layer = Layer.effect( Effect.gen(function* () { const form = yield* create(input) const entry = yield* find(form.id).pipe(Effect.orDie) - return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id)))) + return yield* restore(Deferred.await(entry.deferred)).pipe( + Effect.onInterrupt(() => Effect.ignore(cancel(form.id))), + ) }), ), ) @@ -301,7 +309,8 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined return `Form field has invalid pattern: ${field.key}` } } - if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}` + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) + return `Expected email for form field: ${field.key}` if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}` if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}` if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}` @@ -324,8 +333,10 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined if (field.type === "multiselect") { if (!isStringArray(value)) return `Expected string array for form field: ${field.key}` if (field.required && value.length === 0) return `Missing required form field: ${field.key}` - if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}` - if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}` + if (field.minItems !== undefined && value.length < field.minItems) + return `Too few selections for form field: ${field.key}` + if (field.maxItems !== undefined && value.length > field.maxItems) + return `Too many selections for form field: ${field.key}` if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) { return `Invalid option for form field: ${field.key}` } diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index ff71477d70..e01b546384 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" +import path, { dirname, isAbsolute, join, relative, sep } from "path" import { realpathSync } from "fs" -import * as NFS from "fs/promises" +import { readdir } from "fs/promises" import { lookup } from "mime-types" import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" @@ -38,6 +38,7 @@ export namespace FSUtil { readonly ensureDir: (path: string) => Effect.Effect readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect readonly readDirectoryEntries: (path: string) => Effect.Effect + readonly resolve: (path: string) => Effect.Effect readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect @@ -49,7 +50,9 @@ export namespace FSUtil { export const use = serviceUse(Service) - const layer = Layer.effect( + // Exported so simulation can wrap this layer and override the methods that + // bypass the injected FileSystem (readDirectoryEntries, glob, globUp). + export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -77,7 +80,7 @@ export namespace FSUtil { const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { return yield* Effect.tryPromise({ try: async () => { - const entries = await NFS.readdir(dirPath, { withFileTypes: true }) + const entries = await readdir(dirPath, { withFileTypes: true }) return entries.map( (e): DirEntry => ({ name: e.name, @@ -89,6 +92,14 @@ export namespace FSUtil { }) }) + const resolve = Effect.fn("FileSystem.resolve")(function* (input: string) { + const resolved = path.resolve(windowsPath(input)) + return yield* fs.realPath(resolved).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)), + Effect.orDie, + ) + }) + const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { const text = yield* fs.readFileString(path) return yield* Effect.try({ @@ -187,6 +198,7 @@ export namespace FSUtil { isDir, isFile, readDirectoryEntries, + resolve, readJson, writeJson, ensureDir, @@ -209,7 +221,7 @@ export namespace FSUtil { export function normalizePath(p: string): string { if (process.platform !== "win32") return p - const resolved = pathResolve(windowsPath(p)) + const resolved = path.resolve(windowsPath(p)) try { return realpathSync.native(resolved) } catch { @@ -227,7 +239,7 @@ export namespace FSUtil { } export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) + const resolved = path.resolve(windowsPath(p)) try { return normalizePath(realpathSync(resolved)) } catch (e: any) { diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts index be1efc446a..b4d6e496ea 100644 --- a/packages/core/src/id/id.ts +++ b/packages/core/src/id/id.ts @@ -1,4 +1,4 @@ -import { create as createIdentifier } from "@opencode-ai/schema/identifier" +import { create } from "@opencode-ai/schema/identifier" const prefixes = { job: "job", @@ -23,7 +23,7 @@ export function descending(prefix: keyof typeof prefixes, given?: string) { function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { if (!given) { - return create(prefixes[prefix], direction) + return createID(prefixes[prefix], direction) } if (!given.startsWith(prefixes[prefix])) { @@ -32,10 +32,12 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as return given } -export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { - return prefix + "_" + createIdentifier(direction === "descending", timestamp) +function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + return prefix + "_" + create(direction === "descending", timestamp) } +export { createID as create } + /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ export function timestamp(id: string): number { const prefix = id.split("_")[0] diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 94d9e787ce..66d9e14c01 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -43,22 +43,24 @@ const layer = Layer.effect( }) const observe = Effect.fn("InstructionContext.observe")(function* () { - const start = FSUtil.resolve(location.directory) - const stop = FSUtil.resolve(location.project.directory) + const start = yield* fs.resolve(location.directory) + const stop = yield* fs.resolve(location.project.directory) const fromProject = relative(stop, start) const insideProject = fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( - (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject - ? [] - : yield* fs.up({ - targets: ["AGENTS.md"], - start, - stop, - }) - ).map(FSUtil.resolve), + yield* Effect.forEach( + Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }), + fs.resolve, + ), ) - const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) const files = yield* Effect.forEach( paths, (path) => diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 9acbbe93eb..12dad07dd3 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -406,7 +406,7 @@ const layer = Layer.effect( .get() .integrations.get(input.integrationID) ?.methods.some((method) => method.type === "key") - if (!method) return yield* Effect.die(`Key method not found: ${input.integrationID}`) + if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`)) yield* credentials.create({ integrationID: input.integrationID, label: input.label, @@ -418,7 +418,7 @@ const layer = Layer.effect( oauth: Effect.fn("Integration.connection.oauth")(function* (input) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { - return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) + return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) } const attemptScope = yield* Scope.fork(scope) const authorization = yield* authorize(method.authorize(input.inputs)).pipe( @@ -475,7 +475,7 @@ const layer = Layer.effect( attempt: { status: Effect.fn("Integration.attempt.status")(function* (attemptID) { const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) - if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`) + if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`)) if (attempt.status === "failed") { return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } } @@ -488,12 +488,13 @@ const layer = Layer.effect( if (match.authorization.mode === "code" && input.code === undefined) return [match, current] return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] }) - if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) + if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`)) if (attempt.status !== "pending") return if (attempt.authorization.mode === "code" && input.code === undefined) { return yield* new CodeRequiredError({ attemptID: input.attemptID }) } - if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`) + if (attempt.completing) + return yield* Effect.die(new Error(`OAuth attempt already completing: ${input.attemptID}`)) const callback = attempt.authorization.mode === "auto" ? attempt.authorization.callback diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 1af35e61a1..f0f2d63e7f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -22,14 +22,13 @@ import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginInternal } from "./plugin/internal" import { Policy } from "./policy" -import { Project } from "./project" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" -import * as SessionRunnerLLM from "./session/runner/llm" +import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" import { SessionTitle } from "./session/title" @@ -49,8 +48,7 @@ import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" -export const locationServices = LayerNode.group([ - Project.node, +const locationServiceNodes = [ Location.node, Policy.node, Config.node, @@ -96,7 +94,9 @@ export const locationServices = LayerNode.group([ Snapshot.node, SessionRunnerLLM.node, Vcs.node, -]) +] as const satisfies readonly Node.LocationNode[] + +export const locationServices = LayerNode.group(locationServiceNodes) export type LocationServices = LayerNode.Output export type LocationError = LayerNode.Error diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index fcd0814fb8..84ccbd70bb 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -46,7 +46,11 @@ const TolerantListPromptsResult = ListPromptsResultSchema.extend({ export class NeedsAuthError extends Schema.TaggedErrorClass()("MCP.NeedsAuthError", { server: Schema.String, -}) {} +}) { + override get message() { + return `MCP server requires authentication: ${this.server}` + } +} export class ConnectError extends Schema.TaggedErrorClass()("MCP.ConnectError", { server: Schema.String, diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 2075c8dd19..d4935dc512 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -127,7 +127,11 @@ export class ResourceContent extends Schema.Class("MCP.Resource export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { server: ServerName, -}) {} +}) { + override get message() { + return `MCP server not found: ${this.server}` + } +} export class ToolCallError extends Schema.TaggedErrorClass()("MCP.ToolCallError", { server: ServerName, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index cf2991168a..a88aebfe07 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -47,7 +47,7 @@ const Cost = Schema.Struct({ const ReasoningOption = Schema.Union([ Schema.Struct({ type: Schema.Literal("effort"), - values: Schema.Array(Schema.String), + values: Schema.Array(Schema.Union([Schema.String, Schema.Null])), }), Schema.Struct({ type: Schema.Literal("toggle"), @@ -67,7 +67,7 @@ export const Model = Schema.Struct({ attachment: Schema.Boolean, reasoning: Schema.Boolean, reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), - temperature: Schema.Boolean, + temperature: Schema.optional(Schema.Boolean), tool_call: Schema.Boolean, interleaved: Schema.optional( Schema.Union([ @@ -125,6 +125,10 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type +const Providers = Schema.Record(Schema.String, Provider) +const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers)) +const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers) + export const Event = ModelsDev.Event declare const OPENCODE_MODELS_DEV: Record | undefined @@ -176,6 +180,7 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.flatMap(decodeProvidersUnknown), Effect.catch((error) => { if ( Flag.OPENCODE_MODELS_PATH === undefined && @@ -186,11 +191,17 @@ const layer = Layer.effect( } return Effect.succeed(undefined) }), - Effect.map((v) => v as Record | undefined), ) const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, + ).pipe( + Effect.flatMap((snapshot) => + snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot), + ), + Effect.catch((cause) => + Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)), + ), ) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { @@ -221,7 +232,7 @@ const layer = Layer.effect( return yield* fetchAndWrite() }), ) - return JSON.parse(text) as Record + return yield* decodeProviders(text) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 791d0a9bcd..3a8dc12b25 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,7 +1,7 @@ export * as PermissionV2 from "./permission" import { makeLocationNode } from "./effect/app-node" -import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" import { Location } from "./location" @@ -11,7 +11,9 @@ import { SessionStore } from "./session/store" import { Wildcard } from "./util/wildcard" import { PermissionSaved } from "./permission/saved" -export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" +const PermissionEffect = Permission.Effect +export { PermissionEffect as Effect } +export { Rule, Ruleset } from "@opencode-ai/schema/permission" const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] export const ID = Permission.ID @@ -90,12 +92,12 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { } export interface Interface { - readonly ask: (input: AssertInput) => EffectRuntime.Effect - readonly assert: (input: AssertInput) => EffectRuntime.Effect - readonly reply: (input: ReplyInput) => EffectRuntime.Effect - readonly get: (id: ID) => EffectRuntime.Effect - readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect> - readonly list: () => EffectRuntime.Effect> + readonly ask: (input: AssertInput) => Effect.Effect + readonly assert: (input: AssertInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect> + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/v2/Permission") {} @@ -108,7 +110,7 @@ interface Pending { const layer = Layer.effect( Service, - EffectRuntime.gen(function* () { + Effect.gen(function* () { const events = yield* EventV2.Service const location = yield* Location.Service const agents = yield* AgentV2.Service @@ -116,28 +118,25 @@ const layer = Layer.effect( const saved = yield* PermissionSaved.Service const pending = new Map() - yield* EffectRuntime.addFinalizer(() => - EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { + yield* Effect.addFinalizer(() => + Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { discard: true, }).pipe( - EffectRuntime.ensuring( - EffectRuntime.sync(() => { + Effect.ensuring( + Effect.sync(() => { pending.clear() }), ), ), ) - const savedRules = EffectRuntime.fnUntraced(function* () { + const savedRules = Effect.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), ) }) - const configured = EffectRuntime.fn("PermissionV2.configured")(function* ( - sessionID: SessionV2.ID, - agentID?: AgentV2.ID, - ) { + const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) const agent = yield* agents.resolve(agentID ?? session.agent) @@ -152,7 +151,7 @@ const layer = Layer.effect( return rules.filter((rule) => Wildcard.match(input.action, rule.action)) } - const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) { + const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) { const rules = yield* configured(input.sessionID, input.agent) if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] @@ -174,29 +173,30 @@ const layer = Layer.effect( } const create = (request: Request, agent?: AgentV2.ID) => - EffectRuntime.uninterruptible( - EffectRuntime.gen(function* () { + Effect.uninterruptible( + Effect.gen(function* () { const deferred = yield* Deferred.make() const item = { request, agent, deferred } - if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) + if (pending.has(request.id)) + return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`)) pending.set(request.id, item) yield* events .publish(Event.Asked, request) - .pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id)))) + .pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id)))) return item }), ) - const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { + const ask = Effect.fn("PermissionV2.ask")(function* (input: AssertInput) { const result = yield* evaluateInput(input) const value = request(input) if (result.effect === "ask") yield* create(value, input.agent) return { id: value.id, effect: result.effect } }) - const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) => - EffectRuntime.uninterruptibleMask((restore) => - EffectRuntime.gen(function* () { + const assert = Effect.fn("PermissionV2.assert")((input: AssertInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { const result = yield* evaluateInput(input) if (result.effect === "deny") { return yield* new DeniedError({ @@ -206,8 +206,8 @@ const layer = Layer.effect( if (result.effect === "allow") return const item = yield* create(request(input), input.agent) return yield* restore(Deferred.await(item.deferred)).pipe( - EffectRuntime.ensuring( - EffectRuntime.sync(() => { + Effect.ensuring( + Effect.sync(() => { pending.delete(item.request.id) }), ), @@ -216,9 +216,9 @@ const layer = Layer.effect( ), ) - const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => - EffectRuntime.uninterruptible( - EffectRuntime.gen(function* () { + const reply = Effect.fn("PermissionV2.reply")((input: ReplyInput) => + Effect.uninterruptible( + Effect.gen(function* () { const existing = pending.get(input.requestID) if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) yield* events.publish(Event.Replied, { @@ -261,7 +261,7 @@ const layer = Layer.effect( for (const [id, item] of pending) { const input = { ...item.request } const rules = yield* configured(item.request.sessionID, item.agent).pipe( - EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), + Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)), ) if (!rules) continue if (denied(input, rules)) continue @@ -284,15 +284,15 @@ const layer = Layer.effect( ), ) - const list = EffectRuntime.fn("PermissionV2.list")(function* () { + const list = Effect.fn("PermissionV2.list")(function* () { return Array.from(pending.values(), (item) => item.request) }) - const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) { + const get = Effect.fn("PermissionV2.get")(function* (id: ID) { return pending.get(id)?.request }) - const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { + const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index dd80647a73..a6ff337c62 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -48,7 +48,7 @@ const layer = Layer.effect( let host: Parameters[0] const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { - if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`)) yield* locks.withLock(id)( Effect.sync(() => { @@ -90,7 +90,7 @@ const layer = Layer.effect( }) const remove = Effect.fn("Plugin.remove")(function* (id: ID) { - if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) + if (loading.has(id)) return yield* Effect.die(new Error(`Cannot remove plugin ${id} while it is loading`)) yield* locks.withLock(id)( State.batch( diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 00ff7c82af..d07ba6f0fd 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,6 @@ export * as PluginHost from "./host" -import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, Schema } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" @@ -40,7 +40,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: Parameters[0]) => + const locationRef = (input?: Parameters[0]) => input?.location === undefined ? undefined : Location.Ref.make({ @@ -305,5 +305,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, - } satisfies Interface + } satisfies PluginContext }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 82e2319a1b..e8974195a6 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Deferred, Effect, Semaphore, Stream } from "effect" +import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" import type { Scope } from "effect" import { Credential } from "../../credential" import { EventV2 } from "../../event" @@ -32,11 +32,16 @@ type TokenResponse = { expires_in?: number } -type Claims = { - chatgpt_account_id?: string - organizations?: Array<{ id: string }> - "https://api.openai.com/auth"?: { chatgpt_account_id?: string } -} +const Claims = Schema.fromJsonString( + Schema.Struct({ + chatgpt_account_id: Schema.optional(Schema.String), + organizations: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String }))), + "https://api.openai.com/auth": Schema.optional( + Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String) }), + ), + }), +) +const decodeClaims = Schema.decodeUnknownOption(Claims) const browser = { integrationID: Integration.ID.make("openai"), @@ -315,14 +320,11 @@ function extractAccountID(tokens: TokenResponse) { function claim(token: string) { const part = token.split(".")[1] if (!part) return - try { - const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims - return ( - claims.chatgpt_account_id ?? - claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? - claims.organizations?.[0]?.id - ) - } catch { - return - } + const claims = Option.getOrUndefined(decodeClaims(Buffer.from(part, "base64url").toString())) + if (!claims) return + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) } diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 8c668d8b41..b6c3a540e6 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -19,17 +19,18 @@ export const SapAICorePlugin = define({ const installedPath = evt.package.startsWith("file://") ? evt.package : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint - if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`)) - const mod = yield* Effect.promise(async () => { - return (await import( - installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href - )) as Record any> - }).pipe(Effect.orDie) + const mod: Record = yield* Effect.promise( + () => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href), + ) const match = Object.keys(mod).find((name) => name.startsWith("create")) - if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`)) + const factory = mod[match] + if (typeof factory !== "function") + return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`)) - evt.sdk = mod[match]( + evt.sdk = factory( serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } : {}, diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index cb5b40fd17..e79b498bd4 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -31,7 +31,7 @@ export interface Cell { export const makeCell = (): Cell => ({}) -const unavailable = () => Effect.die("Plugin runtime is unavailable") as Effect.Effect +const unavailable = () => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect) => Effect.suspend(() => { const runtime = cell.runtime diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 782f182b0e..d1dbb3e37c 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,22 +1,23 @@ export * as Policy from "./policy" import { makeLocationNode } from "./effect/app-node" -import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" -export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) -export type Effect = typeof Effect.Type +const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) +export { PolicyEffect as Effect } +export type Effect = typeof PolicyEffect.Type export class Info extends Schema.Class("Policy.Info")({ action: Schema.String, - effect: Effect, + effect: PolicyEffect, resource: Schema.String, }) {} export interface Interface { - readonly load: (statements: Info[]) => EffectRuntime.Effect - readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect + readonly load: (statements: Info[]) => Effect.Effect + readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect readonly hasStatements: () => boolean } @@ -24,16 +25,16 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, - EffectRuntime.gen(function* () { + Effect.gen(function* () { let statements: Info[] = [] yield* Location.Service return Service.of({ - load: EffectRuntime.fn("Policy.load")(function* (input) { + load: Effect.fn("Policy.load")(function* (input) { statements = input }), hasStatements: () => statements.length > 0, - evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) { + evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) { return ( statements.findLast( (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index b42df4045c..9183e26eb9 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -139,7 +139,7 @@ const layer = Layer.effect( }) const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { - const resolved = AbsolutePath.make(FSUtil.resolve(input)) + const resolved = AbsolutePath.make(yield* fs.resolve(input)) if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input }) return resolved }) @@ -202,7 +202,8 @@ const layer = Layer.effect( const copyDirectory = yield* canonical(input.directory) const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory }) if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory }) - yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({ + const strategy = yield* getStrategy(StrategyID.make(stored.strategy)) + yield* strategy.remove({ directory: copyDirectory, force: input.force, }) diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index ab05fdac4a..6a7e5a9d54 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -1,11 +1,11 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" -import * as DatabasePath from "../database/path" +import { absoluteArrayColumn, absoluteColumn } from "../database/path" import { Timestamps } from "../database/schema.sql" import { ProjectSchema } from "./schema" export const ProjectTable = sqliteTable("project", { id: text().$type().primaryKey(), - worktree: DatabasePath.absoluteColumn().notNull(), + worktree: absoluteColumn().notNull(), vcs: text(), name: text(), icon_url: text(), @@ -13,7 +13,7 @@ export const ProjectTable = sqliteTable("project", { icon_color: text(), ...Timestamps, time_initialized: integer(), - sandboxes: DatabasePath.absoluteArrayColumn().notNull(), + sandboxes: absoluteArrayColumn().notNull(), commands: text({ mode: "json" }).$type<{ start?: string }>(), }) @@ -24,7 +24,7 @@ export const ProjectDirectoryTable = sqliteTable( .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - directory: DatabasePath.absoluteColumn().notNull(), + directory: absoluteColumn().notNull(), type: text().$type<"main" | "root" | "git_worktree">(), strategy: text(), time_created: integer() diff --git a/packages/core/src/pty/pty.bun.ts b/packages/core/src/pty/pty.bun.ts index 1f8ce8e454..b92ba4409b 100644 --- a/packages/core/src/pty/pty.bun.ts +++ b/packages/core/src/pty/pty.bun.ts @@ -1,10 +1,10 @@ -import { spawn as create } from "bun-pty" +import { spawn } from "bun-pty" import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" -export function spawn(file: string, args: string[], opts: Opts): Proc { - const pty = create(file, args, opts) +function spawnPty(file: string, args: string[], opts: Opts): Proc { + const pty = spawn(file, args, opts) return { pid: pty.pid, onData(listener) { @@ -24,3 +24,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc { }, } } + +export { spawnPty as spawn } diff --git a/packages/core/src/pty/pty.node.ts b/packages/core/src/pty/pty.node.ts index 76f415f4cd..cc775c8fda 100644 --- a/packages/core/src/pty/pty.node.ts +++ b/packages/core/src/pty/pty.node.ts @@ -1,3 +1,4 @@ +// ast-grep-ignore: no-star-import import * as pty from "@lydell/node-pty" import type { Opts, Proc } from "./pty" diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 07838b1415..2f227aa142 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -32,7 +32,7 @@ function matches(record: Scope, input: Scope) { // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is // never invoked; it dies if it ever is, which would signal a misuse of the Service interface. -const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") +const noLookup = () => Effect.die(new Error("PtyTicket cache must be used via set/invalidateWhen, never get")) // Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. export const make = (ttl: Duration.Input = DEFAULT_TTL) => diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index ac8ea52d93..7e1056c4d8 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -35,6 +35,7 @@ const RawMatch = Schema.Struct({ ), }), }) +const decodeJsonRecord = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString) type RawMatchData = (typeof RawMatch.Type)["data"] @@ -232,10 +233,7 @@ const layer = Layer.effect( parse: (line) => (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) - : Effect.try({ - try: () => JSON.parse(line) as unknown, - catch: (cause) => failure("Invalid ripgrep JSON output", cause), - }) + : decodeJsonRecord(line).pipe(Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause))) ).pipe( Effect.flatMap((json) => { if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4f1be4151c..108d36707a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -41,6 +41,9 @@ import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" import { Job } from "./job" import { CommandV2 } from "./command" +import { Identifier } from "./util/identifier" +import { Shell } from "./shell" +import { KeyedMutex } from "./effect/keyed-mutex" export const RevertState = Revert.State export type RevertState = Revert.State @@ -106,7 +109,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { - operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]), + operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]), }, ) {} @@ -208,8 +211,7 @@ export interface Interface { id?: EventV2.ID sessionID: SessionSchema.ID command: string - resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -255,6 +257,8 @@ const layer = Layer.effect( const locations = yield* LocationServiceMap.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope + const activeShells = new Set() + const shellLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -268,6 +272,19 @@ const layer = Layer.effect( ), ) + // Session shell is user-initiated and synchronous at the API boundary, while + // the Location shell service owns process lifecycle and file-backed output. + const runShellCommand = (command: string, cwd: string) => + Effect.gen(function* () { + const shell = yield* Shell.Service + const info = yield* shell.create({ command, cwd }) + yield* shell.wait(info.id) + const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES }) + return output.output || "(no output)" + }).pipe( + Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")), + ) + const result = Service.of({ create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() @@ -346,8 +363,7 @@ const layer = Layer.effect( yield* events.publish(SessionEvent.Forked, { sessionID, parentID: parent.id, - messageID: input.messageID, - timestamp: yield* DateTime.now, + from: input.messageID, }) return yield* result.get(sessionID).pipe(Effect.orDie) }), @@ -489,7 +505,10 @@ const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) yield* execution.wake(admitted.sessionID) + if (input.resume !== false) { + if (activeShells.has(admitted.sessionID)) return admitted + yield* execution.wake(admitted.sessionID) + } return admitted }), ), @@ -525,8 +544,39 @@ const layer = Layer.effect( resume: input.resume, }) }), - shell: Effect.fn("V2Session.shell")(function* () { - return yield* new OperationUnavailableError({ operation: "shell" }) + shell: Effect.fn("V2Session.shell")(function* (input) { + const session = yield* result.get(input.sessionID) + yield* shellLocks.withLock(input.sessionID)( + Effect.gen(function* () { + activeShells.add(input.sessionID) + if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) + const callID = Identifier.ascending() + yield* events.publish( + SessionEvent.Shell.Started, + { + sessionID: input.sessionID, + callID, + command: input.command, + }, + { id: input.id }, + ) + const output = yield* runShellCommand(input.command, session.location.directory).pipe( + Effect.provide(locations.get(session.location)), + ) + yield* events.publish(SessionEvent.Shell.Ended, { + sessionID: input.sessionID, + callID, + output, + }) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + activeShells.delete(input.sessionID) + yield* execution.wake(input.sessionID) + }), + ), + ), + ) }), skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) @@ -535,8 +585,6 @@ const layer = Layer.effect( if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) yield* events.publish(SessionEvent.Skill.Activated, { sessionID: input.sessionID, - messageID: input.id ?? SessionMessage.ID.create(), - timestamp: yield* DateTime.now, name: skill.name, text: skill.content, }) @@ -547,10 +595,8 @@ const layer = Layer.effect( }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, agent: input.agent, }) }), @@ -562,10 +608,8 @@ const layer = Layer.effect( (session.model.variant ?? "default") === (input.model.variant ?? "default") ) return - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, model: input.model, }) }), @@ -573,7 +617,6 @@ const layer = Layer.effect( yield* result.get(input.sessionID) yield* events.publish(SessionEvent.Renamed, { sessionID: input.sessionID, - timestamp: yield* DateTime.now, title: input.title, }) }), @@ -621,8 +664,6 @@ const layer = Layer.effect( yield* result.get(input.sessionID) yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: input.text, description: input.description, metadata: input.metadata, @@ -679,6 +720,9 @@ const resolvePrompt = (input: PromptInput.Prompt) => }), }) +// Mirrors the shell tool's in-memory preview safety limit. +const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 + export const node = makeGlobalNode({ service: Service, layer: layer.pipe(Layer.orDie), diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 9ff953be82..1174b65f40 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -2,14 +2,12 @@ export * as SessionCompaction from "./compaction" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" import { Context, DateTime, Effect, Layer, Stream } from "effect" -import type { Config } from "../config" -import { Config as ConfigV2 } from "../config" -import type { EventV2 } from "../event" -import { EventV2 as EventV2Service } from "../event" +import { Config } from "../config" +import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" -import { SessionMessage } from "./message" +import type { SessionMessage } from "./message" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { Token } from "../util/token" @@ -208,11 +206,8 @@ const make = (dependencies: Dependencies) => { const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) if (Token.estimate(summaryPrompt) > context - summaryOutput) return false - const messageID = SessionMessage.ID.create() yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, - messageID, - timestamp: yield* DateTime.now, reason: input.reason, }) @@ -240,8 +235,6 @@ const make = (dependencies: Dependencies) => { if (!summarized || failed || !summary.trim()) return false yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, - messageID, - timestamp: yield* DateTime.now, reason: input.reason, text: summary, recent: input.recent, @@ -311,9 +304,9 @@ const make = (dependencies: Dependencies) => { export const layer = Layer.effect( Service, Effect.gen(function* () { - const events = yield* EventV2Service.Service + const events = yield* EventV2.Service const llm = yield* LLMClient.Service - const config = yield* ConfigV2.Service + const config = yield* Config.Service const models = yield* SessionRunnerModel.Service const compaction = make({ events, llm, config: yield* config.entries() }) @@ -336,5 +329,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node], + deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node], }) diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/context-checkpoint.ts index c2036e726a..977514e8ca 100644 --- a/packages/core/src/session/context-checkpoint.ts +++ b/packages/core/src/session/context-checkpoint.ts @@ -1,13 +1,12 @@ export * as SessionContextCheckpoint from "./context-checkpoint" import { eq } from "drizzle-orm" -import { DateTime, Effect, Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" import { SystemContext } from "../system-context/index" import { SessionEvent } from "./event" import { SessionHistory } from "./history" -import { SessionMessage } from "./message" import { SessionSchema } from "./schema" import { SessionContextCheckpointTable } from "./sql" @@ -19,7 +18,7 @@ const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied) * Loads or creates the session's durable context 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 turn leaves pending inputs untouched. + * input promotion so a blocked first step leaves pending inputs untouched. */ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( db: DatabaseService, @@ -50,7 +49,7 @@ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( yield* events.publish( SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { sessionID, text: result.text }, { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, ) return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } @@ -112,7 +111,7 @@ const rewrite = Effect.fnUntraced(function* ( .returning({ sessionID: SessionContextCheckpointTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context checkpoint not found") + if (!updated) return yield* Effect.die(new Error("Context checkpoint not found")) }) const advance = Effect.fnUntraced(function* ( @@ -127,5 +126,5 @@ const advance = Effect.fnUntraced(function* ( .returning({ sessionID: SessionContextCheckpointTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context checkpoint not found") + if (!updated) return yield* Effect.die(new Error("Context checkpoint not found")) }) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8609162c82..4e68e6c3d9 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -19,8 +19,8 @@ const layer = Layer.effect( const coordinator = yield* SessionRunCoordinator.make({ drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) - if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) + return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) @@ -36,7 +36,6 @@ const layer = Layer.effect( Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined yield* events.publish(SessionEvent.ExecutionSettled, { sessionID, - timestamp: yield* DateTime.now, outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", error: failure !== undefined diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index a704a631f7..9a6a75fe2f 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -34,7 +34,7 @@ const messageRows = Effect.fnUntraced(function* ( and( eq(SessionMessageTable.session_id, sessionID), // Keep system updates visible in the gap between a completed compaction - // and the next prepared turn's rebaseline, when their content is not yet + // and the next prepared step's rebaseline, when their content is not yet // folded into a new baseline. compaction ? or( diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 2c6aeda460..1d0dfc71c3 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -50,19 +50,17 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) { const existing = yield* find(db, input.id) if (existing !== undefined) return existing - const timestamp = yield* DateTime.now return yield* events .publish(SessionEvent.PromptAdmitted, { - messageID: input.id, + inputID: input.id, sessionID: input.sessionID, - timestamp, prompt: input.prompt, delivery: input.delivery, }) .pipe( Effect.flatMap((event) => event.durable === undefined - ? Effect.die("Prompt admission event is missing aggregate sequence") + ? Effect.die(new Error("Prompt admission event is missing aggregate sequence")) : Effect.succeed( Admitted.make({ admittedSeq: event.durable.seq, @@ -70,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( sessionID: input.sessionID, prompt: input.prompt, delivery: input.delivery, - timeCreated: timestamp, + timeCreated: event.created, }), ), ), @@ -115,14 +113,11 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( +export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, ) { @@ -141,15 +136,16 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio .pipe(Effect.orDie) if (updated) { const stored = fromRow(updated) - if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return + if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return stored } - // Every Prompted event is published from an admitted inbox row, so a missing or + // Every PromptPromoted event is published from an admitted inbox row, so a missing or // divergent row on replay is an invariant violation. const stored = yield* find(db, input.id) - if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return stored }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -206,12 +202,9 @@ const publish = Effect.fn("SessionInput.publish")(function* ( for (const row of rows) { const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.Prompted, { + .publish(SessionEvent.PromptPromoted, { sessionID, - timestamp: DateTime.makeUnsafe(row.time_created), - messageID: id, - prompt: decodePrompt(row.prompt), - delivery: row.delivery, + inputID: id, }) .pipe( Effect.catchDefect((defect) => diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts index 3e2eb27147..22f003e5ea 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -35,10 +35,10 @@ const layer = Layer.effect( // Resolved once for the Location layer; the synthetic text and dedup ledger keep // absolute paths, but the human-facing description shows paths relative to the project // root so opening a subdirectory still describes paths from the project root. - const root = FSUtil.resolve(location.project.directory) - // Same-turn parallel reads settle concurrently, so an in-memory claim guards each + const root = yield* fs.resolve(location.project.directory) + // Same-step parallel reads settle concurrently, so an in-memory claim guards each // Session/path pair before any filesystem work. The durable history check below covers - // paths injected in earlier turns after this Location layer was reopened. + // paths injected in earlier steps after this Location layer was reopened. const injected = yield* Ref.make>>(new Map()) const load = Effect.fn("SessionInstructions.load")(function* (input: { @@ -60,9 +60,9 @@ const layer = Layer.effect( const files = yield* Effect.forEach( toInject, (path) => - fs.readFileStringSafe(path).pipe( - Effect.map((content) => (content === undefined ? undefined : { path, content })), - ), + fs + .readFileStringSafe(path) + .pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))), { concurrency: "unbounded" }, ) const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) @@ -74,8 +74,6 @@ const layer = Layer.effect( // metadata so it survives across Location layer restarts. yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"), description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`, metadata: { instruction: { paths: readable.map((file) => file.path) } }, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 9ee8f5f739..a23ecb224c 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -19,7 +19,7 @@ export interface Adapter { export function memory(state: MemoryState): Adapter { const assistantIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) - // A newer turn supersedes stale incomplete rows; never resume an older assistant projection. + // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") const activeShellIndex = (callID: string) => state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) @@ -100,112 +100,100 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { - "session.next.agent.switched": (event) => { + "agent.selected": (event) => { return adapter.appendMessage( - SessionMessage.AgentSwitched.make({ - id: event.data.messageID, + SessionMessage.AgentSelected.make({ + id: SessionMessage.ID.fromEvent(event.id), type: "agent-switched", metadata: event.metadata, agent: event.data.agent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.model.switched": (event) => { + "model.selected": (event) => { return adapter.appendMessage( - SessionMessage.ModelSwitched.make({ - id: event.data.messageID, + SessionMessage.ModelSelected.make({ + id: SessionMessage.ID.fromEvent(event.id), type: "model-switched", metadata: event.metadata, model: event.data.model, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.moved": () => Effect.void, - "session.next.renamed": () => Effect.void, - "session.next.forked": () => Effect.void, - "session.next.prompted": (event) => { - return adapter.appendMessage( - SessionMessage.User.make({ - id: event.data.messageID, - type: "user", - metadata: event.metadata, - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timestamp }, - }), - ) - }, - "session.next.prompt.admitted": () => Effect.void, - "session.next.execution.settled": () => Effect.void, - "session.next.context.updated": (event) => + "session.moved": () => Effect.void, + renamed: () => Effect.void, + forked: () => Effect.void, + "prompt.promoted": () => Effect.void, + "prompt.admitted": () => Effect.void, + "execution.settled": () => Effect.void, + "session.context.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "system", text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ), - "session.next.synthetic": (event) => { + synthetic: (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, metadata: event.data.metadata, - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "synthetic", - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.skill.activated": (event) => { + "skill.activated": (event) => { return adapter.appendMessage( SessionMessage.Skill.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "skill", name: event.data.name, text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.shell.started": (event) => { + "shell.started": (event) => { return adapter.appendMessage( SessionMessage.Shell.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, callID: event.data.callID, command: event.data.command, output: "", - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.shell.ended": (event) => { + "shell.ended": (event) => { return Effect.gen(function* () { const currentShell = yield* adapter.getCurrentShell(event.data.callID) if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { draft.output = event.data.output - draft.time.completed = event.data.timestamp + draft.time.completed = event.created }), ) } }) }, - "session.next.step.started": (event) => { + "step.started": (event) => { return Effect.gen(function* () { const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created }), ) } @@ -215,16 +203,16 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "assistant", agent: event.data.agent, model: event.data.model, - time: { created: event.data.timestamp }, + time: { created: event.created }, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, }), ) }) }, - "session.next.step.ended": (event) => { + "step.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created draft.finish = event.data.finish draft.cost = event.data.cost draft.tokens = event.data.tokens @@ -236,33 +224,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.step.failed": (event) => { + "step.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created draft.finish = "error" draft.error = event.data.error }) }, - "session.next.text.started": (event) => { + "text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, - "session.next.text.delta": (event) => { + "text.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text += event.data.delta }) }, - "session.next.text.ended": (event) => { + "text.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text = event.data.text }) }, - "session.next.tool.input.started": (event) => { + "tool.input.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -270,26 +258,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "tool", id: event.data.callID, name: event.data.name, - time: { created: event.data.timestamp }, + time: { created: event.created }, state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), }), ), ) }) }, - "session.next.tool.input.delta": () => Effect.void, - "session.next.tool.input.ended": (event) => { + "tool.input.delta": () => Effect.void, + "tool.input.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "pending") match.state.input = event.data.text }) }, - "session.next.tool.called": (event) => { + "tool.called": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { match.provider = event.data.provider - match.time.ran = event.data.timestamp + match.time.ran = event.created match.state = castDraft( SessionMessage.ToolStateRunning.make({ status: "running", @@ -301,7 +289,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.progress": (event) => { + "tool.progress": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -310,7 +298,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.success": (event) => { + "tool.success": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -319,7 +307,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateCompleted.make({ status: "completed", @@ -333,7 +321,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.failed": (event) => { + "tool.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && (match.state.status === "pending" || match.state.status === "running")) { @@ -342,7 +330,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateError.make({ status: "error", @@ -356,7 +344,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.reasoning.started": (event) => { + "reasoning.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -365,47 +353,47 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ), ) }) }, - "session.next.reasoning.delta": (event) => { + "reasoning.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text += event.data.delta }) }, - "session.next.reasoning.ended": (event) => { + "reasoning.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text - match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } + match.time = { created: match.time?.created ?? event.created, completed: event.created } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) }, - "session.next.retried": () => Effect.void, - "session.next.compaction.started": () => Effect.void, - "session.next.compaction.delta": () => Effect.void, - "session.next.compaction.ended": (event) => { + retried: () => Effect.void, + "compaction.started": () => Effect.void, + "compaction.delta": () => Effect.void, + "compaction.ended": (event) => { return adapter.appendMessage( SessionMessage.Compaction.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "compaction", metadata: event.metadata, reason: event.data.reason, summary: event.data.text, recent: event.data.recent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.revert.staged": () => Effect.void, - "session.next.revert.cleared": () => Effect.void, - "session.next.revert.committed": () => Effect.void, + "revert.staged": () => Effect.void, + "revert.cleared": () => Effect.void, + "revert.committed": () => Effect.void, }) }) } diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 8f4cc4e5df..c5b552e8b4 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -25,7 +25,7 @@ import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" type DatabaseService = Database.Interface["db"] -type MessageEvent = Exclude +type MessageEvent = Exclude const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) @@ -157,22 +157,19 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .where(eq(SessionTable.id, event.data.parentID)) .get() .pipe(Effect.orDie) - if (!parent) return yield* Effect.die(`Fork parent session not found: ${event.data.parentID}`) - const boundary = event.data.messageID + if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`)) + const boundary = event.data.from ? yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and( - eq(SessionMessageTable.session_id, event.data.parentID), - eq(SessionMessageTable.id, event.data.messageID), - ), + and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)), ) .get() .pipe(Effect.orDie) : undefined - if (event.data.messageID && !boundary) - return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) + if (event.data.from && !boundary) + return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) @@ -208,8 +205,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( tokens_reasoning: 0, tokens_cache_read: 0, tokens_cache_write: 0, - time_created: DateTime.toEpochMillis(event.data.timestamp), - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_created: DateTime.toEpochMillis(event.created), + time_updated: DateTime.toEpochMillis(event.created), }) .onConflictDoNothing() .returning({ sessionID: SessionTable.id }) @@ -341,7 +338,8 @@ function run(db: DatabaseService, event: MessageEvent) { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) const updateMessage = (message: SessionMessage.Message) => { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) + return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -360,7 +358,7 @@ function run(db: DatabaseService, event: MessageEvent) { const adapter: SessionMessageUpdater.Adapter = { getCurrentAssistant() { return Effect.gen(function* () { - // A newer turn supersedes stale incomplete rows; never resume an older assistant projection. + // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const row = yield* db .select() .from(SessionMessageTable) @@ -417,8 +415,8 @@ function run(db: DatabaseService, event: MessageEvent) { }) } -function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") +function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) { + if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -438,7 +436,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service - const { db } = yield* Database.Service + const db = (yield* Database.Service).db yield* events.project(SessionV1.Event.Created, (event) => Effect.gen(function* () { const stored = yield* db @@ -473,9 +471,9 @@ const layer = Layer.effectDiscard( .update(SessionTable) .set({ directory: event.data.location.directory, - path: event.data.subdirectory, + path: event.data.subpath, workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_updated: DateTime.toEpochMillis(event.created), }) .where(eq(SessionTable.id, event.data.sessionID)) .run() @@ -555,19 +553,19 @@ const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => + yield* events.project(SessionEvent.AgentSelected, (event) => db .update(SessionTable) - .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie, Effect.andThen(run(db, event))), ) - yield* events.project(SessionEvent.ModelSwitched, (event) => + yield* events.project(SessionEvent.ModelSelected, (event) => Effect.gen(function* () { yield* db .update(SessionTable) - .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) @@ -577,36 +575,43 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Renamed, (event) => db .update(SessionTable) - .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) - yield* events.project(SessionEvent.Prompted, (event) => + yield* events.project(SessionEvent.PromptPromoted, (event) => Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* SessionInput.projectPrompted(db, { - id: event.data.messageID, + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) + const input = yield* SessionInput.projectPromptPromoted(db, { + id: event.data.inputID, sessionID: event.data.sessionID, - prompt: event.data.prompt, - delivery: event.data.delivery, - timeCreated: event.data.timestamp, promotedSeq: event.durable.seq, }) - yield* run(db, event) + yield* insertMessage(db, event, { + id: input.id, + type: "user", + metadata: event.metadata, + text: input.prompt.text, + files: input.prompt.files, + agents: input.prompt.agents, + time: { created: event.created }, + }) }), ) yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) yield* SessionInput.projectAdmitted(db, { admittedSeq: event.durable.seq, - id: event.data.messageID, + id: event.data.inputID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, - timeCreated: event.data.timestamp, + timeCreated: event.created, }) }), ) @@ -614,11 +619,11 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Skill.Activated, (event) => insertMessage(db, event, { - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "skill", name: event.data.name, text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) @@ -643,7 +648,7 @@ const layer = Layer.effectDiscard( .update(SessionTable) .set({ revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_updated: DateTime.toEpochMillis(event.created), }) .where(eq(SessionTable.id, event.data.sessionID)) .run() @@ -652,7 +657,7 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => db .update(SessionTable) - .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie, Effect.asVoid), @@ -670,7 +675,7 @@ const layer = Layer.effectDiscard( ) .get() .pipe(Effect.orDie) - if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`) + if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`)) yield* db .delete(SessionMessageTable) .where( @@ -690,7 +695,7 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie) yield* db .update(SessionTable) - .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 9999d5da5a..8c0b8bbf7b 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -89,7 +89,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { } satisfies SessionSchema.Info["revert"] yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID: input.session.id, - timestamp: yield* DateTime.now, revert, }) return revert @@ -106,7 +105,6 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID: session.id, - timestamp: yield* DateTime.now, }) }) @@ -116,6 +114,5 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, messageID: session.revert.messageID, - timestamp: yield* DateTime.now, }) }) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 11141c3447..7c2a6f463d 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -9,16 +9,12 @@ import type { SystemContext } from "../../system-context/index" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = - | LLMError - | SessionRunnerModel.Error - | MessageDecodeError - | SystemContext.InitializationBlocked - | ToolOutputStore.Error + LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ export interface Interface { - /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ - readonly run: (input: { + /** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */ + readonly drain: (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) => Effect.Effect diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 42e9674904..0aeaa0e052 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,3 +1,5 @@ +export * as SessionRunnerLLM from "./llm" + import { LLM, LLMClient, @@ -59,11 +61,11 @@ import { llmClient } from "../../effect/app-node-platform" * - Runtime context assembly * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. * - * - One provider turn + * - 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)` provider turn. + * - [x] Stream exactly one `llm.stream(request)` physical 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. @@ -75,8 +77,8 @@ import { llmClient } from "../../effect/app-node-platform" * - [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 provider turn after local tool results. - * - [x] Continue for durable user steering accepted during an active provider turn. + * - [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 @@ -84,12 +86,12 @@ import { llmClient } from "../../effect/app-node-platform" * - [ ] 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 provider turn. Keep tool execution and continuation here. + * Use `llm.stream(request)` for each physical 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 - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an - * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. + * 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( @@ -112,14 +114,14 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service - // Title generation is a side effect of the first turn; it must not delay turn continuation. + // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. const titleAttempted = new Set() const forkTitle = yield* FiberSet.makeRuntime() const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) - if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) return session }) @@ -132,7 +134,6 @@ const layer = Layer.effect( if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue yield* events.publish(SessionEvent.Tool.Failed, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID: message.id, callID: tool.id, error: { type: "unknown", message: "Tool execution interrupted" }, @@ -165,7 +166,7 @@ const layer = Layer.effect( { concurrency: "unbounded" }, ).pipe(Effect.map(SystemContext.combine)) - const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( + const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -176,7 +177,7 @@ const layer = Layer.effect( return yield* Effect.interrupt const agent = yield* agents.select(session.agent) // Establish what the model knows before admitting what the user said, so - // a blocked first turn leaves pending inputs untouched. + // a blocked first step leaves pending inputs untouched. const checkpoint = yield* SessionContextCheckpoint.prepare( db, events, @@ -230,7 +231,7 @@ const layer = Layer.effect( snapshot: startSnapshot, }) const publication = Semaphore.makeUnsafe(1) - // Durable publishes are serialized so tool fibers and turn settlement never interleave + // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => @@ -281,7 +282,7 @@ const layer = Layer.effect( Effect.ensuring(serialized(publisher.flush())), ) - // Captures the end snapshot, diffs it against the turn's start, and durably ends the + // Captures the end snapshot, diffs it against the step's start, and durably ends the // assistant step. const publishStepEnd = (settlement: NonNullable>) => Effect.gen(function* () { @@ -295,7 +296,6 @@ const layer = Layer.effect( yield* serialized( events.publish(SessionEvent.Step.Ended, { sessionID: session.id, - timestamp: yield* DateTime.now, assistantMessageID: yield* publisher.startAssistant(), finish: settlement.finish, cost: 0, @@ -316,7 +316,7 @@ const layer = Layer.effect( const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause) // A context overflow before any assistant output is recoverable: compact and - // restart the turn instead of surfacing the provider error. + // restart the step instead of surfacing the provider error. if ( recoverOverflow && !publisher.hasAssistantStarted() && @@ -325,7 +325,7 @@ const layer = Layer.effect( ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const - // An unrecovered held-back overflow becomes the turn's durable provider error. A + // An unrecovered held-back overflow becomes the step's durable provider error. A // thrown LLM failure fails hosted tool calls and the assistant unless a provider // error was already recorded from the stream. if (overflowFailure) yield* publish(overflowFailure) @@ -346,12 +346,12 @@ const layer = Layer.effect( if (questionDismissed || streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - yield* serialized(publisher.failAssistant("Provider turn interrupted")) + yield* serialized(publisher.failAssistant("Step interrupted")) // Match V1: dismissing a question halts the loop like an interruption. if (questionDismissed) return yield* Effect.interrupt } // 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 turn 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 // could not be persisted) also fails the assistant and then fails the drain. const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined @@ -387,7 +387,7 @@ const layer = Layer.effect( ) }, Effect.scoped) - const runTurn = Effect.fnUntraced(function* ( + const runStep = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -399,7 +399,7 @@ const layer = Layer.effect( let currentPromotion = promotion let currentStep = step while (true) { - const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow) + const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow) if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined yield* Effect.yieldNow @@ -410,7 +410,7 @@ const layer = Layer.effect( // ExecutionSettled is published per execution (busy period) by SessionExecution, not per // drain here. - const run = Effect.fn("SessionRunner.run")(function* (input: { + const drain = Effect.fn("SessionRunner.drain")(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { @@ -423,9 +423,13 @@ const layer = Layer.effect( while (shouldRun) { let needsContinuation = true let step = 1 + // Repeat steps while continuation is needed. A step needs continuation only + // when it recorded local tool calls whose results the model has not yet seen; + // a provider error suppresses it. Pending steers also continue the loop so + // interjections are answered before the session goes idle. while (needsContinuation) { - const result = yield* runTurn(input.sessionID, promotion, step) - // Steer/queue promotion inside runTurn has already made the pending input a visible + const result = yield* runStep(input.sessionID, promotion, step) + // Steer/queue promotion inside runStep has already made the pending input a visible // user message by this point, so the first-user-message check below is reliable. if (!titleAttempted.has(input.sessionID)) { titleAttempted.add(input.sessionID) @@ -441,7 +445,7 @@ const layer = Layer.effect( } }) - return Service.of({ run }) + return Service.of({ drain }) }), ) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b21492d18d..2b81e609ea 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -2,8 +2,11 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" +// ast-grep-ignore: no-star-import import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" +// ast-grep-ignore: no-star-import import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" +// ast-grep-ignore: no-star-import import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Schema } from "effect" diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 33652a618c..9a334a0a6d 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -50,7 +50,7 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): return { structured: record(settled.structured), content: settled.content } } -/** Persist one provider turn without executing tools or starting a continuation turn. */ +/** Persist one step without executing tools or starting a continuation step. */ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => { const tools = new Map< string, @@ -78,14 +78,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, - timestamp: yield* timestamp, snapshot: input.snapshot, }) return assistantMessageID }) const currentAssistantMessageID = () => assistantMessageID === undefined - ? Effect.die("Tool event before assistant step start") + ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) const fragments = ( @@ -95,20 +94,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const chunks = new Map() const start = (id: string) => Effect.suspend(() => { - if (chunks.has(id)) return Effect.die(`Duplicate ${name} start: ${id}`) + if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) chunks.set(id, []) return Effect.void }) const append = (id: string, value: string) => Effect.suspend(() => { const current = chunks.get(id) - if (!current) return Effect.die(`${name} delta before start: ${id}`) + if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) current.push(value) return Effect.void }) const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) { const current = chunks.get(id) - if (!current) return yield* Effect.die(`${name} end before start: ${id}`) + if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) yield* ended(id, current.join(""), providerMetadata) chunks.delete(id) }) @@ -123,7 +122,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Ended, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, textID, text: value, }) @@ -134,7 +132,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, reasoningID, text: value, providerMetadata, @@ -144,10 +141,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const toolInput = fragments("tool input", (callID, value) => Effect.gen(function* () { const tool = tools.get(callID) - if (!tool) return yield* Effect.die(`Tool input end before start: ${callID}`) + if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`)) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID, text: value, @@ -163,7 +159,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) }) const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { - if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`) + if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`)) const assistantMessageID = yield* startAssistant() tools.set(event.id, { assistantMessageID, @@ -176,7 +172,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* toolInput.start(event.id) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID, callID: event.id, name: event.name, @@ -185,10 +180,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { const tool = tools.get(event.id) - if (!tool) return yield* Effect.die(`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) - return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`) + 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}`)) yield* toolInput.end(event.id) }) @@ -204,7 +199,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantFailed = true yield* events.publish(SessionEvent.Step.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID, error: { type: "unknown", message }, }) @@ -219,7 +213,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) tool.settled = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID, error: { type: "unknown", message }, @@ -233,7 +226,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const assistantMessageIDForTool = (callID: string) => { const tool = tools.get(callID) - return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`) + return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`)) } const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( @@ -248,7 +241,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - timestamp: yield* timestamp, textID: event.id, }) return @@ -257,7 +249,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, textID: event.id, delta: event.text, }) @@ -270,7 +261,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - timestamp: yield* timestamp, reasoningID: event.id, providerMetadata: event.providerMetadata, }) @@ -280,7 +270,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, reasoningID: event.id, delta: event.text, }) @@ -293,14 +282,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "tool-input-delta": { const tool = tools.get(event.id) - if (!tool) return yield* Effect.die(`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) - return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`) + 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}`)) yield* toolInput.append(event.id, event.text) yield* events.publish(SessionEvent.Tool.Input.Delta, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, delta: event.text, @@ -315,14 +303,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const tool = tools.get(event.id)! if (!tool.inputEnded) yield* endToolInput(event) if (tool.name !== event.name) - return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`) + 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}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true tool.providerMetadata = event.providerMetadata yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, tool: event.name, @@ -336,12 +323,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "tool-result": { const tool = tools.get(event.id) - if (!tool?.called) return yield* Effect.die(`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) - return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`) + return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)) if (tool.settled) { if (event.result.type === "error") return - return yield* Effect.die(`Duplicate tool result: ${event.id}`) + return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) } tool.settled = true const result = settledOutput(event.output, event.result) @@ -352,7 +339,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if ("error" in result) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, error: result.error, @@ -363,7 +349,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } yield* events.publish(SessionEvent.Tool.Success, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, ...result, @@ -375,14 +360,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "tool-error": { const tool = tools.get(event.id) - if (!tool?.called) return yield* Effect.die(`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) - return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`) + return yield* Effect.die(new Error(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool error: ${event.id}`)) tool.settled = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, error: { type: "unknown", message: event.message }, @@ -396,7 +380,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) case "step-finish": yield* flush() assistantActive = false - if (stepSettlement) return yield* Effect.die("Duplicate step finish") + if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 8ddd7cce5c..0d9055e480 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -7,6 +7,7 @@ import { type Model, type ProviderMetadata, } from "@opencode-ai/llm" +import { Option, Schema } from "effect" import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" @@ -18,14 +19,12 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) -const toolInput = (tool: SessionMessage.AssistantTool) => { - if (tool.state.status !== "pending") return tool.state.input - try { - return JSON.parse(tool.state.input) as unknown - } catch { - return tool.state.input - } -} +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +const toolInput = (tool: SessionMessage.AssistantTool) => + tool.state.status === "pending" + ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) + : tool.state.input const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart => ToolCallPart.make({ diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index d1e923b40f..739d3f1888 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,5 +1,5 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" -import * as DatabasePath from "../database/path" +import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "./prompt" @@ -31,8 +31,8 @@ export const SessionTable = sqliteTable( workspace_id: text().$type(), parent_id: text().$type(), slug: text().notNull(), - directory: DatabasePath.directoryColumn().notNull(), - path: DatabasePath.pathColumn(), + directory: directoryColumn().notNull(), + path: pathColumn(), title: text().notNull(), version: text().notNull(), share_url: text(), diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index abed5ce33c..8e7b5857b7 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -42,9 +42,10 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const resolved = yield* (agent.model - ? dependencies.models.resolve({ ...session, model: agent.model }) - : dependencies.models.resolve(session) + const resolved = yield* ( + agent.model + ? dependencies.models.resolve({ ...session, model: agent.model }) + : dependencies.models.resolve(session) ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] @@ -76,7 +77,6 @@ const make = (dependencies: Dependencies) => { if (!title) return yield* dependencies.events.publish(SessionEvent.Renamed, { sessionID: session.id, - timestamp: yield* DateTime.now, title: truncate(title), }) }) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 110697421d..311fb7c9a1 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -4,7 +4,7 @@ import path from "path" import { spawn, type ChildProcess } from "child_process" import { readFile } from "fs/promises" import { statSync } from "fs" -import { setTimeout as sleep } from "node:timers/promises" +import { setTimeout } from "node:timers/promises" import { Flag } from "../flag/flag" import { FSUtil } from "../fs-util" import { which } from "../util/which" @@ -46,13 +46,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole try { process.kill(-pid, "SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) + await setTimeout(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } } catch { proc.kill("SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) + await setTimeout(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { proc.kill("SIGKILL") } diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index ce6aaae454..d86efceb91 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -12,7 +12,7 @@ import { Effect, Option, Schema } from "effect" * The durable `Applied` record tracks what the model was last told, per source: * it is the model's current belief. Interpreters uphold one invariant — * `reconcile` never rewrites the baseline; it only narrates drift as update - * text. Only `rebaseline` (compaction) and `initialize` (first turn) produce + * text. Only `rebaseline` (compaction) and `initialize` (first step) produce * baseline text. * * Returning `unavailable` means observation failed temporarily. It differs from diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 028456a4e1..16e229d97d 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -97,8 +97,8 @@ export const Plugin = { // skipped, and discovery failures never fail the read. yield* Effect.gen(function* () { if (target.externalDirectory !== undefined) return - const resolved = FSUtil.resolve(target.canonical) - const root = FSUtil.resolve(location.directory) + const resolved = yield* fs.resolve(target.canonical) + const root = yield* fs.resolve(location.directory) // up() searches its stop directory, so the Location-root AGENTS.md (already // supplied by the core/instructions baseline) is dropped by the dirname filter. const discovered = yield* fs.up({ @@ -106,7 +106,7 @@ export const Plugin = { start: type === "directory" ? resolved : dirname(resolved), stop: root, }) - const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root) + const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) if (candidates.length === 0) return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) }).pipe( diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index e86f960ba5..cb7edb3113 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -80,17 +80,21 @@ const modelOutput = (output: Output): string | undefined => { const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") -const externalCommandDirectories = (command: string, cwd: string) => { +const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectories")(function* ( + fs: FSUtil.Interface, + command: string, + cwd: string, +) { const directories = new Set() for (const token of shellTokens(command)) { const value = unquote(token).replace(/[;,|&]+$/, "") if (!path.isAbsolute(value)) continue - const resolved = FSUtil.resolve(value) + const resolved = yield* fs.resolve(value) if (FSUtil.contains(cwd, resolved)) continue - directories.add(FSUtil.resolve(path.dirname(resolved))) + directories.add(yield* fs.resolve(path.dirname(resolved))) } return [...directories] -} +}) export const Plugin = { id: "core-shell-tool", @@ -168,7 +172,7 @@ export const Plugin = { agent: context.agent, source, }) - const warnings = externalCommandDirectories(input.command, target.canonical).map( + const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map( (directory) => `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, ) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b381cc7418..b36de1abac 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -255,7 +255,7 @@ describe("DatabaseMigration", () => { ) yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, ) yield* db.run( sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b40f088c5d..65c370f1eb 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -22,14 +22,14 @@ const locationLayer = Layer.succeed( location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }), ), ) -const Message = EventV2.define({ +const Message = EventV2.ephemeral({ type: "test.message", schema: { text: Schema.String, }, }) -const SyncMessage = EventV2.define({ +const SyncMessage = EventV2.durable({ type: "test.sync", durable: { version: 1, @@ -41,7 +41,7 @@ const SyncMessage = EventV2.define({ }, }) -const SyncSent = EventV2.define({ +const SyncSent = EventV2.durable({ type: "test.sent", durable: { version: 1, @@ -53,14 +53,14 @@ const SyncSent = EventV2.define({ }, }) -const GlobalMessage = EventV2.define({ +const GlobalMessage = EventV2.ephemeral({ type: "test.global", schema: { text: Schema.String, }, }) -const VersionedMessage = EventV2.define({ +const VersionedMessage = EventV2.durable({ type: "test.versioned", durable: { version: 2, @@ -129,12 +129,12 @@ describe("EventV2", () => { it.effect("selects the latest durable definition independent of declaration order", () => Effect.sync(() => { - const latest = EventV2.define({ + const latest = EventV2.durable({ type: "test.out-of-order", durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) - const historical = EventV2.define({ + const historical = EventV2.durable({ type: "test.out-of-order", durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, @@ -554,6 +554,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -573,6 +574,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -609,6 +611,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID: envelopeAggregateID, @@ -642,6 +645,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -650,6 +654,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 5, aggregateID, @@ -674,13 +679,14 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), seq: 0, aggregateID, - data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, + data: { sessionID: aggregateID, text: "context" }, }) - expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) + expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0)) }), ) @@ -690,6 +696,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: "unknown.event.1", seq: 0, aggregateID: EventV2.ID.create(), @@ -708,6 +715,7 @@ describe("EventV2", () => { const source = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -715,6 +723,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -735,6 +744,7 @@ describe("EventV2", () => { const one = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -742,6 +752,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -751,6 +762,7 @@ describe("EventV2", () => { const two = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, @@ -758,6 +770,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 3, aggregateID, @@ -793,6 +806,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -812,6 +826,7 @@ describe("EventV2", () => { const id = EventV2.ID.create() const replayed = { id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -833,6 +848,7 @@ describe("EventV2", () => { const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const replayed = { id: published.id, + created: published.created, type: EventV2.versionedType(DurableMessage.type, 1), seq: published.durable!.seq, aggregateID, @@ -867,6 +883,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -895,6 +912,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -905,6 +923,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, @@ -937,6 +956,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -949,6 +969,7 @@ describe("EventV2", () => { .replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -970,6 +991,7 @@ describe("EventV2", () => { yield* events.listen((event) => Effect.sync(() => received.push(event))) const replayed = { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -990,6 +1012,7 @@ describe("EventV2", () => { const aggregateID = Session.ID.create() const replayed = { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1014,6 +1037,7 @@ describe("EventV2", () => { const id = EventV2.ID.create() yield* events.replay({ id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1023,6 +1047,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -1045,6 +1070,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1055,6 +1081,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -1116,6 +1143,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 7ea351df3a..e032cf6c71 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -92,6 +92,79 @@ describe("Form", () => { }), ) + it.effect("requires every when condition to match and treats empty when as active", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "global", + mode: "form", + fields: [ + { key: "a", type: "boolean" }, + { key: "b", type: "boolean" }, + { + key: "x", + type: "string", + required: true, + when: [ + { key: "a", op: "eq", value: true }, + { key: "b", op: "eq", value: true }, + ], + }, + { key: "z", type: "string", required: true, when: [] }, + ], + }) + + 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" })) + + const inactiveX = yield* service + .reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } }) + .pipe(Effect.flip) + 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) + 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" } }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } }) + }), + ) + + it.effect("evaluates neq against multiselect answers as non-inclusion", () => + Effect.gen(function* () { + const service = yield* Form.Service + const options = [ + { value: "go", label: "Go" }, + { value: "ts", label: "TypeScript" }, + ] + const created = yield* service.create({ + sessionID: "global", + mode: "form", + fields: [ + { key: "langs", type: "multiselect", options }, + { key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] }, + ], + }) + + const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip) + expect(missing).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }), + ) + + // an answered-but-empty multiselect also satisfies neq + const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip) + expect(missingEmpty).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }), + ) + + 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" })) + + yield* service.reply({ id: created.id, answer: { langs: ["go"] } }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } }) + }), + ) + it.effect("treats unanswered when references as false and cascades inactivity", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts new file mode 100644 index 0000000000..15ff95a28f --- /dev/null +++ b/packages/core/test/mcp.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import { MCP } from "@opencode-ai/core/mcp/index" +import { MCPClient } from "@opencode-ai/core/mcp/client" + +describe("MCP errors", () => { + test("expose useful messages", () => { + expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") + expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe( + "failed", + ) + expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") + expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") + }) +}) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index d9b7ed5582..9aa1cedc4d 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -149,6 +149,22 @@ describe("ModelsDev Service", () => { }), ) + it.effect("allows models.dev entries without temperature metadata", () => + Effect.sync(() => { + const result = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "no-temperature-model", + name: "No Temperature Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + + expect(result.temperature).toBeUndefined() + }), + ) + it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index cee32ade04..4a9a520d59 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -85,17 +85,13 @@ describe("SessionV2.compact", () => { const prompt = Prompt.make({ text: "Please compact this session history." }) yield* events.publish(SessionEvent.PromptAdmitted, { sessionID: created.id, - messageID, - timestamp: DateTime.makeUnsafe(0), + inputID: messageID, prompt, delivery: "steer", }) - yield* events.publish(SessionEvent.Prompted, { + yield* events.publish(SessionEvent.PromptPromoted, { sessionID: created.id, - messageID, - timestamp: DateTime.makeUnsafe(0), - prompt, - delivery: "steer", + inputID: messageID, }) yield* session.compact({ sessionID: created.id }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 9c41d1d9ac..0100ca4ae9 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -17,11 +17,11 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -62,6 +62,13 @@ const assertCreateInputTypes = (session: SessionV2.Interface) => { } void assertCreateInputTypes +function withTmp(f: (directory: string) => Effect.Effect) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(tmp.path))) +} + describe("SessionV2.create", () => { it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { @@ -190,8 +197,6 @@ describe("SessionV2.create", () => { yield* SessionInput.promoteSteers(db, events, parent.id) yield* events.publish(SessionEvent.Synthetic, { sessionID: parent.id, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: "parent note", }) @@ -208,7 +213,7 @@ describe("SessionV2.create", () => { expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(history).toHaveLength(1) expect(history[0]).toMatchObject({ - type: "session.next.forked", + type: "forked", durable: { seq: 0 }, data: { sessionID: forked.id, parentID: parent.id }, }) @@ -260,7 +265,7 @@ describe("SessionV2.create", () => { const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) - expect(history[0]).toMatchObject({ data: { messageID: second.id } }) + expect(history[0]).toMatchObject({ data: { from: second.id } }) }), ) @@ -373,8 +378,8 @@ describe("SessionV2.create", () => { expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.next.prompted" }, + { durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "prompt.promoted" }, ]) }), ) @@ -399,6 +404,7 @@ describe("SessionV2.create", () => { .all() .pipe(Effect.orDie)).map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -459,7 +465,7 @@ describe("SessionV2.create", () => { ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], + [2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), @@ -476,20 +482,41 @@ describe("SessionV2.create", () => { }), ) - it.effect("reports unfinished Session operations as unavailable", () => - Effect.gen(function* () { - const session = yield* SessionV2.Service - const created = yield* session.create({ location }) - const unavailable = ( - effect: Effect.Effect, - ) => - effect.pipe( - Effect.flip, - Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), - ) + it.live("runs a shell command and projects the started/ended shell message", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) - expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") - }), + yield* session.shell({ sessionID: created.id, command: "echo hello" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", command: "echo hello" }) + expect(shell?.output).toContain("hello") + expect(shell?.time.completed).toBeDefined() + }), + ), + ) + + it.live("still emits shell ended for a failing command", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + + yield* session.shell({ sessionID: created.id, command: "false" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", command: "false" }) + expect(shell?.time.completed).toBeDefined() + }), + ), ) it.effect("switches the selected agent through the durable Session event", () => @@ -502,7 +529,7 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + ).toMatchObject([{ type: "agent.selected", data: { agent: "plan" } }]) }), ) @@ -535,7 +562,7 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ model }) expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) + ).toMatchObject([{ type: "model.selected", data: { model } }]) }), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index e87fce7234..daf0308b6f 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -133,8 +133,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) => const events = yield* EventV2.Service yield* events.publish(SessionEvent.Synthetic, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: `Instructions from: ${paths[0]}\nprior`, description: `Loaded ${paths[0]}`, metadata: { instruction: { paths } }, diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index b80dd2c206..8c49b94582 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -47,7 +47,7 @@ describe("SessionV2.log", () => { // Session creation commits a non-public durable event, so the marker's // seq covers more of the aggregate than the public events emitted. - expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.synced"]) + expect(items.map((item) => item.type)).toEqual(["renamed", "log.synced"]) expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark }) }), ) @@ -64,7 +64,7 @@ describe("SessionV2.log", () => { yield* session.rename({ sessionID: created.id, title: "renamed live" }) const items = Array.from(yield* Fiber.join(fiber)) - expect(items.map((item) => item.type)).toEqual(["log.synced", "session.next.renamed"]) + expect(items.map((item) => item.type)).toEqual(["log.synced", "renamed"]) }), ) @@ -78,7 +78,7 @@ describe("SessionV2.log", () => { it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () => Effect.gen(function* () { - const GapEvent = EventV2.define({ + const GapEvent = EventV2.durable({ type: "test.session.log.gap", durable: { aggregate: "sessionID", version: 1 }, schema: { sessionID: SessionV2.ID, value: Schema.String }, diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 4191a58715..c0d172088e 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -79,7 +79,6 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: DateTime.makeUnsafe(1), revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ @@ -87,17 +86,15 @@ describe("SessionProjector", () => { snapshot: "tree", files: [], }) - yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) }) + yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: DateTime.makeUnsafe(3), revert: { messageID: boundary, files: [] }, }) yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, messageID: boundary, - timestamp: DateTime.makeUnsafe(4), }) expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), @@ -131,37 +128,29 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID: SessionMessage.ID.make("msg_first"), - timestamp: created, + inputID: SessionMessage.ID.make("msg_first"), prompt: Prompt.make({ text: "first" }), delivery: "steer", }) yield* events.publish( - SessionEvent.Prompted, + SessionEvent.PromptPromoted, { sessionID, - messageID: SessionMessage.ID.make("msg_first"), - timestamp: created, - prompt: Prompt.make({ text: "first" }), - delivery: "steer", + inputID: SessionMessage.ID.make("msg_first"), }, { id: EventV2.ID.make("evt_z") }, ) yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID: SessionMessage.ID.make("msg_second"), - timestamp: created, + inputID: SessionMessage.ID.make("msg_second"), prompt: Prompt.make({ text: "second" }), delivery: "steer", }) yield* events.publish( - SessionEvent.Prompted, + SessionEvent.PromptPromoted, { sessionID, - messageID: SessionMessage.ID.make("msg_second"), - timestamp: created, - prompt: Prompt.make({ text: "second" }), - delivery: "steer", + inputID: SessionMessage.ID.make("msg_second"), }, { id: EventV2.ID.make("evt_a") }, ) @@ -190,7 +179,7 @@ describe("SessionProjector", () => { }).pipe(Effect.provide(sessionsLayer)), ) - it.effect("marks an inbox row promoted with the Prompted event sequence", () => + it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -220,12 +209,9 @@ describe("SessionProjector", () => { }) if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.Prompted, { + const event = yield* events.publish(SessionEvent.PromptPromoted, { sessionID, - timestamp: admitted.timeCreated, - messageID: id, - prompt: Prompt.make({ text: "promote me" }), - delivery: "steer", + inputID: id, }) expect( @@ -256,49 +242,36 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, agent: "build", }) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, model, }) yield* events.publish(SessionEvent.Synthetic, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, text: "synthetic context", metadata: { source: "projector-test" }, }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, callID: "shell-1", command: "pwd", }) yield* events.publish(SessionEvent.Shell.Ended, { sessionID, - timestamp: DateTime.makeUnsafe(1), callID: "shell-1", output: "/project", }) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: created, reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, - messageID: compactionID, - timestamp: created, text: "partial", }) expect( @@ -319,8 +292,6 @@ describe("SessionProjector", () => { ).toEqual([]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", text: "summary", recent: "recent context", @@ -350,7 +321,7 @@ describe("SessionProjector", () => { }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ output: "/project", - time: { completed: DateTime.makeUnsafe(1) }, + time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ summary: "summary", @@ -388,13 +359,20 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") + const { + id: _, + type, + ...data + } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) + yield* db + .insert(SessionMessageTable) + .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) + .run() - yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" }) const exit = yield* events .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, - timestamp: created, agent: "build", model, }) @@ -464,7 +442,6 @@ describe("SessionProjector", () => { const service = yield* EventV2.Service yield* service.publish(SessionEvent.Step.Ended, { sessionID, - timestamp: DateTime.makeUnsafe(1), assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", cost: 0, @@ -485,7 +462,7 @@ describe("SessionProjector", () => { expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", - time: { completed: DateTime.makeUnsafe(1) }, + time: { completed: DateTime.makeUnsafe(0) }, }) }), ) @@ -526,7 +503,6 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), - timestamp: DateTime.makeUnsafe(3), textID: "text-stale", }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 58e26b7b5c..58a613a324 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => { yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: yield* DateTime.now, revert: { messageID: boundary.id, files: [] }, }) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) @@ -257,16 +256,16 @@ describe("SessionV2.prompt", () => { const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ - [0, "session.next.prompt.admitted"], - [1, "session.next.prompt.admitted"], - [2, "session.next.prompted"], - [3, "session.next.prompted"], + [0, "prompt.admitted"], + [1, "prompt.admitted"], + [2, "prompt.promoted"], + [3, "prompt.promoted"], ]) expect( Array.from( yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]), - ).toEqual([[1, "session.next.prompt.admitted"]]) + ).toEqual([[1, "prompt.admitted"]]) }), ) @@ -429,7 +428,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => { yield* events.replayAll( recorded.map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { + const { db } = yield* Database.Service + const { + id: _, + type, + ...data + } = encodeMessage({ + id: messageID, sessionID, - messageID, - timestamp: yield* DateTime.now, + type: "synthetic", text: "Existing history", + time: { created: DateTime.makeUnsafe(0) }, }) + yield* db + .insert(SessionMessageTable) + .values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data }) + .run() + .pipe(Effect.orDie) const failure = yield* session .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a8..d33b44728d 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -51,13 +51,13 @@ describe("toLLMMessages", () => { const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( [ - SessionMessage.AgentSwitched.make({ + SessionMessage.AgentSelected.make({ id: id("agent"), type: "agent-switched", agent: "build", time: { created }, }), - SessionMessage.ModelSwitched.make({ + SessionMessage.ModelSelected.make({ id: id("model"), type: "model-switched", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, @@ -354,7 +354,7 @@ Recent work state: SessionMessage.ToolStateError.make({ status: "error", input: { query: "Effect" }, - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [], structured: {}, }), @@ -362,7 +362,7 @@ Recent work }), ], finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, time: { created, completed: created }, }), ], @@ -386,7 +386,7 @@ Recent work result: { type: "error", value: { - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [], structured: {}, }, diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 0ebe7a6f9e..4f82baba96 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -98,7 +98,7 @@ const execution = Layer.effect( Effect.gen(function* () { const sessionRunner = yield* SessionRunner.Service const coordinator = yield* SessionRunCoordinator.make({ - drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), }) return SessionExecution.Service.of({ active: coordinator.active, @@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.next.prompt.admitted.1", - "session.next.prompted.1", - "session.next.step.started.1", - "session.next.text.started.1", - "session.next.text.ended.1", - "session.next.step.ended.2", + "prompt.admitted.1", + "prompt.promoted.1", + "step.started.1", + "text.started.1", + "text.ended.1", + "step.ended.1", ]) }), ) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 048766e439..5c8d7e07f4 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(result)) - const success = published.find((event) => event.type === "session.next.tool.success.1") + const success = published.find((event) => event.type === "tool.success.1") expect(success).toBeDefined() const serialized = JSON.stringify(success) expect(serialized.split(base64)).toHaveLength(2) @@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) - const success = published.find((event) => event.type === "session.next.tool.success.1") + const success = published.find((event) => event.type === "tool.success.1") expect(success?.data).toHaveProperty("result") }) @@ -110,14 +110,13 @@ test("binary failure emits no success event", async () => { }), ), ) - expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false) - expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true) + expect(published.some((event) => event.type === "tool.success.1")).toBe(false) + expect(published.some((event) => event.type === "tool.failed.1")).toBe(true) }) test("old success event data containing result still decodes", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, - timestamp: Date.now(), assistantMessageID: SessionMessage.ID.create(), callID: "call-old", structured: { type: "media", mime: "image/png" }, @@ -133,6 +132,6 @@ test("step finish records settlement without publishing step ended", async () => await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) - expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false) + expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 45699e7112..e6f14c8e19 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -260,7 +260,7 @@ const execution = Layer.effect( Effect.gen(function* () { const sessionRunner = yield* SessionRunner.Service const coordinator = yield* SessionRunCoordinator.make({ - drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), }) return SessionExecution.Service.of({ active: coordinator.active, @@ -423,6 +423,7 @@ const replaySessionProjection = (id: SessionV2.ID) => yield* events.replayAll( recorded.map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -574,7 +575,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => ) const runner = yield* SessionRunner.Service - const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild) + const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) yield* Deferred.await(streamed) yield* Fiber.interrupt(fiber) expect(yield* session.context(sessionID)).toMatchObject([ @@ -582,7 +583,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -740,7 +741,6 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, - timestamp: DateTime.makeUnsafe(1), location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) expect( @@ -848,7 +848,7 @@ describe("SessionRunnerLLM", () => { yield* db .select({ id: EventTable.id }) .from(EventTable) - .where(eq(EventTable.type, "session.next.context.updated.1")) + .where(eq(EventTable.type, "session.context.updated.1")) .all() .pipe(Effect.orDie), ).toHaveLength(1) @@ -1010,10 +1010,8 @@ describe("SessionRunnerLLM", () => { response = [] yield* session.resume(sessionID) skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) @@ -1039,10 +1037,8 @@ describe("SessionRunnerLLM", () => { if (switched) return Effect.void switched = true return events - .publish(SessionEvent.AgentSwitched, { + .publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) .pipe(Effect.asVoid) @@ -1069,10 +1065,8 @@ describe("SessionRunnerLLM", () => { if (switched) return Effect.void switched = true return events - .publish(SessionEvent.ModelSwitched, { + .publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) .pipe(Effect.asVoid) @@ -1175,10 +1169,8 @@ describe("SessionRunnerLLM", () => { systemBaseline = "Changed context" yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" @@ -1217,10 +1209,8 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true @@ -1252,14 +1242,10 @@ describe("SessionRunnerLLM", () => { const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), reason: "manual", text: "summary", recent: "", @@ -1482,14 +1468,10 @@ describe("SessionRunnerLLM", () => { const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), reason: "manual", text: "summary", recent: "", @@ -1686,10 +1668,8 @@ describe("SessionRunnerLLM", () => { toolExecutionsReady = 1 const run = yield* Effect.forkChild(session.resume(sessionID)) yield* Deferred.await(toolExecutionsStarted) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" @@ -2403,27 +2383,23 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", name: "echo", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", text: '{"text":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", tool: "echo", @@ -2467,27 +2443,23 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", name: "web_search", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", text: '{"query":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", tool: "web_search", @@ -2527,13 +2499,11 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-pending-interrupted", name: "echo", @@ -2578,7 +2548,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2603,7 +2573,9 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, + event.type === SessionEvent.PromptPromoted.type + ? Effect.die("fail after prompt promotion commits") + : Effect.void, ) yield* session.prompt({ sessionID, @@ -3011,9 +2983,9 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Interrupt provider" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } }, + { type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } }, ]) - expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2") + expect(yield* recordedEventTypes(sessionID)).toContain("step.failed.1") yield* session.interrupt(sessionID) }), ) @@ -3035,7 +3007,7 @@ describe("SessionRunnerLLM", () => { ] const runner = yield* SessionRunner.Service - const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild) + const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) yield* Deferred.await(toolExecutionsStarted) yield* Fiber.interrupt(run) toolExecutionGate = undefined @@ -3046,7 +3018,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [ { type: "tool", @@ -3057,8 +3029,8 @@ describe("SessionRunnerLLM", () => { }, ]) const eventTypes = yield* recordedEventTypes(sessionID) - expect(eventTypes).toContain("session.next.step.failed.2") - expect(eventTypes).not.toContain("session.next.step.ended.2") + expect(eventTypes).toContain("step.failed.1") + expect(eventTypes).not.toContain("step.ended.1") }), ) @@ -3424,9 +3396,10 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe( - "Duplicate text start: text-1", - ) + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("Duplicate text start: text-1") }), ) @@ -3468,9 +3441,10 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe( - "Tool input delta before start: call-1", - ) + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("Tool input delta before start: call-1") }), ) }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 1e9bcfbeb8..59abb8fabc 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) => const messageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID, - timestamp: DateTime.makeUnsafe(0), + inputID: messageID, prompt: Prompt.make({ text }), delivery: "steer", }) - yield* events.publish(SessionEvent.Prompted, { + yield* events.publish(SessionEvent.PromptPromoted, { sessionID, - messageID, - timestamp: DateTime.makeUnsafe(0), - prompt: Prompt.make({ text }), - delivery: "steer", + inputID: messageID, }) }) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 6d07b14657..e730b3556b 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -51,7 +51,6 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp, agent: "build", model, }) @@ -69,14 +68,12 @@ describe("Tool.Progress", () => { Effect.gen(function* () { yield* service.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp, assistantMessageID, callID, name: "bash", }) yield* service.publish(SessionEvent.Tool.Called, { sessionID, - timestamp, assistantMessageID, callID, tool: "bash", @@ -92,7 +89,6 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Tool.Progress, { sessionID, - timestamp, assistantMessageID, callID: "call-success", structured: { phase: "checkpoint" }, @@ -104,7 +100,6 @@ describe("Tool.Progress", () => { const success = yield* service.publish(SessionEvent.Tool.Success, { sessionID, - timestamp, assistantMessageID, callID: "call-success", structured: { phase: "done" }, @@ -118,7 +113,6 @@ describe("Tool.Progress", () => { yield* start("call-failed") yield* service.publish(SessionEvent.Tool.Progress, { sessionID, - timestamp, assistantMessageID, callID: "call-failed", structured: { phase: "checkpoint" }, @@ -126,7 +120,6 @@ describe("Tool.Progress", () => { }) const failed = yield* service.publish(SessionEvent.Tool.Failed, { sessionID, - timestamp, assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index e7556a8b7c..ecdd555fa8 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -148,8 +148,8 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.UnknownError, SessionMessage.UnknownError], - [coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched], - [coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched], + [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], + [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], [coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 15bd741a2e..6fd43ee2a2 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Step.Started, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, agent: session.agent ?? AgentV2.ID.make("code"), model: sessionModel, }) yield* events.publish(SessionEvent.Text.Started, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, textID, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, textID, text: "ok", }) yield* events.publish(SessionEvent.Step.Ended, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 6274e329aa..4dd46da691 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: AgentV2.ID.make("reviewer"), model: childModel, }) yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, textID, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, textID, text: childText, }) yield* events.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, finish: "stop", cost: 0, tokens, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 656f8179d6..eb3b7141c6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -153,11 +153,11 @@ const OpenAIChatChoice = Schema.Struct({ finish_reason: optionalNull(Schema.String), }) -const OpenAIChatEvent = Schema.Struct({ +export const OpenAIChatEvent = Schema.Struct({ choices: Schema.Array(OpenAIChatChoice), usage: optionalNull(OpenAIChatUsage), }) -type OpenAIChatEvent = Schema.Schema.Type +export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] interface ParserState { diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md new file mode 100644 index 0000000000..c7631cf4cb --- /dev/null +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -0,0 +1,150 @@ +# Simulated Network And Driver-Scripted LLM + +Status: design for the Phase 2 network and LLM items in `simulation-phases.md`. + +## Summary + +Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not a separate fake: it is one registered route in that network (`api.openai.com`), answered by the **external driver** over the existing control WebSocket. When the app issues a provider request, the backend forwards it to the driver and the driver streams response chunks back. There is no enqueueing and no scripted-response store; the driver is the model. + +Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions. + +## Why the network seam + +`LLMClient.stream` sits on a stack that ends in one platform node: + +``` +LLMClient.stream(request) + route.body.from LLMRequest -> OpenAI JSON body (real) + transport.prepare body + endpoint + auth -> HttpRequest (real) + RequestExecutor.execute status/error taxonomy (real) + HttpClient.HttpClient <- replaced by the simulated network + Framing.sse bytes -> frames (real) + protocol.stream.event frame -> OpenAIChatEvent, validated (real) + protocol.stream.step state machine -> LLMEvents (real) +``` + +Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already used by `simulationReplacements` mechanics) keeps the entire pipeline under test and gives wire-fidelity observation of what would have been sent to the provider. Failure injection (429s, malformed SSE, truncated streams) exercises real error paths that a typed `LLMClient` fake cannot reach. + +## Components + +### 1. Simulated network (`packages/simulation/src/backend/network.ts`) + +Replaces `httpClient` in `simulationReplacements`. An in-memory route table: + +- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect`. +- Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default). +- Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it. +- Every request/response summary is traced. + +### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`) + +Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `protocols/openai-chat.ts` (`https://api.openai.com/v1/chat/completions`). + +On request: + +1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). +2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. +3. Return `HttpClientResponse` with `content-type: text/event-stream` whose body stream reads from the queue, encoding each item as an SSE `data:` frame, terminated by `[DONE]`. + +Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime. + +The response stream is interruptible like a real HTTP response: if the runner cancels (user interrupt), the exchange closes and the driver is notified. + +### 3. LLM exchange service (`packages/simulation/src/backend/llm-exchange.ts`) + +Process-global simulation service owning pending exchanges: + +``` +Exchange = { id, body, queue: Queue, deferred lifecycle } +``` + +- `requests()` — stream of newly opened exchanges (consumed by the control route). +- `push(id, item)` — append one response item to an open exchange. +- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. +- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). + +### 4. Backend control WebSocket (simulation-gated) + +Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. + +Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): + +``` +{ "jsonrpc": "2.0", "method": "llm.request", + "params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } +``` + +Driver -> server methods: + +``` +llm.attach subscribe to llm.request notifications +llm.chunk { id, items: Item[] } append response items +llm.finish { id, reason?: "stop" | ... } finish the exchange +llm.pending list open exchanges +network.log simulated network request log +``` + +`Item` is the response vocabulary the driver speaks: + +``` +{ type: "textDelta", text } +{ type: "reasoningDelta", text } +{ type: "toolCall", id, name, input } +{ type: "raw", chunk } // escape hatch: raw OpenAIChatEvent JSON +``` + +The backend compiles items to OpenAI chunks (`delta.content`, `delta.tool_calls[].function.arguments`, `finish_reason`); `raw` passes through unmodified. Streaming granularity is the driver's choice: many small `llm.chunk` calls stream word by word; one call with many items plus `llm.finish` responds at once. + +Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not yet implemented. + +### 5. Driver topology + +A driver manages two loopback WebSocket connections: + +- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. +- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. + +Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. + +### 6. Pacing and the clock + +No server-side pacing by default: the driver controls timing by when it sends chunks, which is the point of driver-in-the-loop. A convenience `llm.chunk` option `{ delayMs }` may sleep via `Effect.sleep` between items server-side; because that uses the fiber `Clock`, scoping a controllable clock to the exchange stream (`Stream.provideService(Clock.Clock, simClock)`) remains available for deterministic replay without touching app time. Defer until replay work needs it. + +### 7. Catalog and auth seeding + +The driver-facing model must be selectable in the TUI. Simulation seeds config (via the snapshot filesystem) defining a provider on the openai-chat route with `baseURL` left at the OpenAI default and a dummy `apiKey` (satisfies `Catalog.available()`). No catalog code changes. + +## End-to-end flow + +``` +driver TUI sim server (40900+) backend + control WS (40950+) + | | | + |-- ui.action (submit) ----->| | + | |-- (normal app HTTP) ---->| session runner starts + | | | llm.stream -> HttpClient + | | | simulated network matches openai route + |<================== llm.request {ex_1} ===============| exchange ex_1 opened + |-- llm.chunk {ex_1,[...]} ============================>| SSE frames flow into the real + |-- llm.chunk {ex_1,[...]} ============================>| decode -> step -> LLMEvents -> + |-- llm.finish {ex_1} =================================>| runner publishes, TUI renders + | | | + | (if toolCall was sent: runner executes the real tool against the + | fake filesystem, then issues the next provider turn -> new exchange + | ex_2 -> driver decides the next response) +``` + +The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet. + +## Implementation order + +1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`. +2. `llm-exchange.ts` + `openai.ts`: exchange service and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). +3. `control.ts`: backend-hosted control WebSocket (`llm.attach|chunk|finish|pending`, `network.log`), started when the simulation module loads. +4. Config seeding for the sim provider; end-to-end verification via `packages/server/script/e2e-sim.ts` (headless) and `packages/tui/script/sim-llm-driver.ts` (TUI + backend sockets). +5. Trace records for network and LLM exchange activity. + +## Consequences + +- No enqueue/script store to keep consistent; the driver is the single source of model behavior. +- Deterministic tests write drivers (respond to `llm.request` programmatically) instead of pre-baked scripts; replay (Phase 4) records exchanges and replays them as an automatic driver. +- Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed. diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index ef7aa41e3f..ebc4bd3743 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -51,6 +51,28 @@ Out of scope: Goal: make the app safe and controlled by swapping the lowest layers, not app logic. +Implementation checklist: + +- [x] Add `packages/simulation/src/backend` as the home for backend simulation layer replacements, exported from `backend/index.ts` as `simulationReplacements`; `@opencode-ai/simulation` is private/non-published and depends on logic/framework packages (`core`, `llm`, `effect`, OpenTUI), while `server` and `tui` consume it. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. +- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. +- [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. +- [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". +- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. +- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATION_ROOT/STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). +- [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). +- [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). +- [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. +- [x] Add backend-hosted simulation control WebSocket (`control.ts`): JSON-RPC on `127.0.0.1:40950+`, started when the simulation module loads. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage two sockets: TUI control (40900+) for UI, backend control (40950+) for LLM/network. +- [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). +- [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. +- [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). +- [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns). +- [ ] Trace filesystem, process, and LLM exchange activity (network requests are traced in the backend network log ring buffer; LLM exchange trace records moved out with the frontend proxy and need re-adding on the backend control server). + Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts index fe6b95267c..60ded4ebd9 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/packages/opencode/src/cli/cmd/run/noninteractive.ts @@ -156,17 +156,16 @@ export async function runNonInteractivePrompt(input: Input) { continue } if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue - const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now() + const time = toMillis(event.created) - if (event.type === "session.next.prompted") { - if (event.data.messageID === messageID) { + if (event.type === "prompt.promoted") { + if (event.data.inputID === messageID) { promoted = true continue } - if (promoted && event.data.delivery === "queue") return } if ( - event.type === "session.next.execution.settled" && + event.type === "execution.settled" && event.data.outcome === "interrupted" && (interrupted || permissionRejected || questionRejected || formCancelled) ) { @@ -174,7 +173,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (!promoted) continue - if (event.type === "session.next.step.started") { + if (event.type === "step.started") { const part: StepStartPart = { id: partID(event.id), sessionID: input.sessionID, @@ -190,11 +189,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.text.started") { + if (event.type === "text.started") { starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "session.next.text.ended") { + if (event.type === "text.ended") { const started = starts.get(event.data.textID) const part: TextPart = { id: started?.id ?? partID(event.id), @@ -208,11 +207,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.reasoning.started") { + if (event.type === "reasoning.started") { starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "session.next.reasoning.ended" && input.thinking) { + if (event.type === "reasoning.ended" && input.thinking) { const started = starts.get(event.data.reasoningID) const part: ReasoningPart = { id: started?.id ?? partID(event.id), @@ -237,7 +236,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.tool.input.started") { + if (event.type === "tool.input.started") { tools.set(event.data.callID, { id: partID(event.id), timestamp: time, @@ -247,12 +246,12 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "session.next.tool.input.ended") { + if (event.type === "tool.input.ended") { const current = tools.get(event.data.callID) if (current) current.raw = event.data.text continue } - if (event.type === "session.next.tool.called") { + if (event.type === "tool.called") { const current = tools.get(event.data.callID) tools.set(event.data.callID, { id: current?.id ?? partID(event.id), @@ -265,7 +264,7 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "session.next.tool.success") { + if (event.type === "tool.success") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const part: ToolPart = { id: current.id, @@ -298,7 +297,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("tool_use", time, { part })) await input.renderTool(part) continue } - if (event.type === "session.next.tool.failed") { + if (event.type === "tool.failed") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const error = event.data.error.message const part: ToolPart = { @@ -329,7 +328,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.step.ended") { + if (event.type === "step.ended") { const part: StepFinishPart = { id: partID(event.id), sessionID: input.sessionID, @@ -343,19 +342,19 @@ export async function runNonInteractivePrompt(input: Input) { emit("step_finish", time, { part }) continue } - if (event.type === "session.next.step.failed") { + if (event.type === "step.failed") { if (interrupted || permissionRejected || questionRejected || formCancelled) continue emittedError = true process.exitCode = 1 if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) continue } - if (event.type === "session.next.execution.settled") { + if (event.type === "execution.settled") { if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } - if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message) + if (!emit("error", time, { error })) UI.error(error.message) } if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 return @@ -458,11 +457,12 @@ function partID(eventID: string) { function fallbackTool(event: { id: string - data: { timestamp: number; assistantMessageID: string; callID: string } + created: number + data: { assistantMessageID: string; callID: string } }): ToolState { return { id: partID(event.id), - timestamp: toMillis(event.data.timestamp), + timestamp: toMillis(event.created), assistantMessageID: event.data.assistantMessageID, tool: "tool", input: {}, diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 05daa8b423..9450f6cf78 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -728,7 +728,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { const data = input.data const event = input.event - if (event.type === "session.next.shell.started") { + if (event.type === "shell.started") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } @@ -748,7 +748,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { return out(data, commits, patch({ status: "running shell" })) } - if (event.type === "session.next.shell.ended") { + if (event.type === "shell.ended") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts index eac116b0b8..ac652423a1 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts @@ -424,21 +424,21 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } const reduce = (child: ChildState, event: V2Event) => { - if (event.type === "session.next.prompted") { - if (userFrame(child, event.data.messageID, event.data.prompt.text)) { - touch(child, event.data.timestamp) + if (event.type === "prompt.promoted") { + if (userFrame(child, event.data.inputID, "")) { + touch(child, event.created) notifyDetail(child) } return } - if (event.type === "session.next.step.started") { - touch(child, event.data.timestamp) + if (event.type === "step.started") { + touch(child, event.created) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.status !== "running") child.status = "running" input.emit() return } - if (event.type === "session.next.text.delta") { + if (event.type === "text.delta") { const projected = child.projectedText.get(event.data.textID) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { @@ -455,11 +455,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID: event.data.assistantMessageID, partID: event.data.textID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.text.ended") { + if (event.type === "text.ended") { child.text.set(event.data.textID, event.data.text) child.projectedText.delete(event.data.textID) setFrame(child, `text:${event.data.textID}`, { @@ -470,11 +470,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID: event.data.assistantMessageID, partID: event.data.textID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.reasoning.delta") { + if (event.type === "reasoning.delta") { const projected = child.projectedReasoning.get(event.data.reasoningID) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { @@ -495,7 +495,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.next.reasoning.ended") { + if (event.type === "reasoning.ended") { child.reasoning.set(event.data.reasoningID, event.data.text) child.projectedReasoning.delete(event.data.reasoningID) if (!input.thinking) return @@ -510,16 +510,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.next.tool.input.started") { - child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp }) + if (event.type === "tool.input.started") { + child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created }) return } - if (event.type === "session.next.tool.called") { + if (event.type === "tool.called") { const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { name: event.data.tool, input: event.data.input, - started: current?.started ?? event.data.timestamp, + started: current?.started ?? event.created, }) childTool( child, @@ -529,18 +529,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac name: event.data.tool, provider: event.data.provider, state: { status: "running", input: event.data.input, structured: {}, content: [] }, - time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, + time: { created: current?.started ?? event.created, ran: event.created }, }, event.data.assistantMessageID, ) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + if (event.type === "tool.success" || event.type === "tool.failed") { if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) - const failed = event.type === "session.next.tool.failed" + const failed = event.type === "tool.failed" childTool( child, { @@ -566,18 +566,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac result: event.data.result, }, time: { - created: current?.started ?? event.data.timestamp, + created: current?.started ?? event.created, ran: current?.started, - completed: event.data.timestamp, + completed: event.created, }, }, event.data.assistantMessageID, ) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.step.failed") { + if (event.type === "step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", source: "system", @@ -585,14 +585,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac phase: "start", messageID: event.data.assistantMessageID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.execution.settled") { + if (event.type === "execution.settled") { child.status = event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" - touch(child, event.data.timestamp) + touch(child, event.created) input.emit() } } @@ -613,15 +613,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return { main(event) { - if (event.type === "session.next.tool.called") { + if (event.type === "tool.called") { if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) return } - if (event.type === "session.next.tool.failed") { + if (event.type === "tool.failed") { pendingCalls.delete(event.data.callID) return } - if (event.type !== "session.next.tool.success") return + if (event.type !== "tool.success") return const pending = pendingCalls.get(event.data.callID) pendingCalls.delete(event.data.callID) const found = childSessionID(record(event.data.structured)) @@ -633,7 +633,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac child.status = "running" } if (!found.running && child.status === "running") child.status = "completed" - touch(child, event.data.timestamp) + touch(child, event.created) input.emit() if (!child.hydrated) void hydrateChild(child) }, diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts index 7700ddd024..614a960634 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -294,7 +294,14 @@ export async function createSessionTransport(input: StreamInput): Promise { @@ -395,17 +402,17 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -445,7 +452,7 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -486,17 +493,17 @@ export async function createSessionTransport(input: StreamInput): Promise 0 ? total.toLocaleString() : "" - write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage }) + write([], { + phase: event.data.finish === "tool-calls" ? "running" : "idle", + usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage, + }) return } - if (event.type === "session.next.step.failed") { + if (event.type === "step.failed") { state.errors.add(event.data.assistantMessageID) if (state.wait) state.wait.failureRendered = true write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) return } - if (event.type === "session.next.execution.settled") { + if (event.type === "execution.settled") { write([], { phase: "idle", status: "" }) const current = state.wait if (!current || (!current.promoted && !current.interrupted)) return @@ -611,7 +628,7 @@ export async function createSessionTransport(input: StreamInput): Promise + const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator try { const first = await stream.next() if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") @@ -689,10 +706,7 @@ export async function createSessionTransport(input: StreamInput): Promise (file.attachment ? [file.attachment] : [])), - ...promptFiles, - ] + const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles] const agents = next.prompt.parts.flatMap((part) => part.type === "agent" ? [ @@ -735,10 +749,7 @@ export async function createSessionTransport(input: StreamInput): Promise (file.text ? [file.text] : [])), - ].join("\n\n"), + text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), files: attachments.length ? attachments : undefined, agents: agents.length ? agents : undefined, }, diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index 15cf799c75..839b99656b 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -18,34 +18,33 @@ function form(id: string, sessionID: string): FormInfo { } function formCreated(info: FormInfo): V2Event { - return { id: `evt_${info.id}`, type: "form.created", data: { form: info } } + return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } } } -function prompted(messageID: string): V2Event { +function prompted(inputID: string): V2Event { return { id: "evt_prompted", - type: "session.next.prompted", - data: { timestamp: 1, sessionID: "ses_1", messageID, prompt: { text: "hello" }, delivery: "steer" }, + created: 0, + type: "prompt.promoted", + durable: { aggregateID: "ses_1", seq: 0, version: 1 }, + data: { sessionID: "ses_1", inputID }, } } function settled(outcome: "success" | "interrupted" = "success"): V2Event { return { id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 2, sessionID: "ses_1", outcome }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome }, } } // Runs one non-interactive prompt against a mocked SDK. `turn` produces the // live events the prompt admission triggers, keyed by the generated message ID. -async function run(input: { - turn: (messageID: string) => V2Event[] - pendingForms?: FormInfo[] - attached?: boolean -}) { +async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) { const sdk = new OpencodeClient() - const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] + const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }] let wake: (() => void) | undefined const stream = (async function* (): AsyncGenerator { while (true) { @@ -64,8 +63,9 @@ async function run(input: { ) spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never) spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never) - spyOn(sdk.v2.session.form, "list").mockImplementation((request) => - ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never, + spyOn(sdk.v2.session.form, "list").mockImplementation( + (request) => + ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never, ) spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never) spyOn(sdk.v2.session, "prompt").mockImplementation((request) => { diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index ec21cd007e..89a6751d1b 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -329,7 +329,7 @@ describe("run session data", () => { test("renders direct shell mode from first-class shell events", () => { let data = createSessionData() const started = reduce(data, { - type: "session.next.shell.started", + type: "shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -353,7 +353,7 @@ describe("run session data", () => { data = started.data const ended = reduce(data, { - type: "session.next.shell.ended", + type: "shell.ended", properties: { sessionID: "session-1", timestamp: 2, @@ -380,7 +380,7 @@ describe("run session data", () => { test("suppresses legacy bash part updates once shell events claim the call", () => { let data = reduce(createSessionData(), { - type: "session.next.shell.started", + type: "shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -409,7 +409,7 @@ describe("run session data", () => { ).toEqual([]) data = reduce(data, { - type: "session.next.shell.ended", + type: "shell.ended", properties: { sessionID: "session-1", timestamp: 2, @@ -463,7 +463,7 @@ describe("run session data", () => { expect( reduce(data, { - type: "session.next.shell.started", + type: "shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -497,7 +497,7 @@ describe("run session data", () => { expect( reduce(data, { - type: "session.next.shell.ended", + type: "shell.ended", properties: { sessionID: "session-1", timestamp: 2, diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 7c2fc9f70e..8bf429e500 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -50,7 +50,11 @@ function ok(data: T) { } function connected(id = "evt_connected") { - return { id, type: "server.connected", data: {} } satisfies RunV2Event + return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event +} + +function durable(sessionID: string, seq = 0, version = 1) { + return { aggregateID: sessionID, seq, version } } function footer() { @@ -94,7 +98,10 @@ function sdk(input: { const client = new OpencodeClient() let subscription = 0 spyOn(client.v2.event, "subscribe").mockImplementation( - () => Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType, + () => + Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType< + typeof client.v2.event.subscribe + >, ) spyOn(client.v2.session, "messages").mockImplementation((request) => ok({ @@ -118,22 +125,20 @@ function sdk(input: { spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) // The generated methods have conditional return types for throwOnError; the // minimal shapes below are enough for family discovery and model fallback. - spyOn(client.v2.session, "list").mockImplementation( - (request) => { - const parentID = request?.parentID - return ok({ - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: - input.sessions?.filter((session) => - parentID === undefined - ? true - : parentID === null - ? session.parentID === undefined - : session.parentID === parentID, - ) ?? [], - }) as never - }, - ) + spyOn(client.v2.session, "list").mockImplementation((request) => { + const parentID = request?.parentID + return ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: + input.sessions?.filter((session) => + parentID === undefined + ? true + : parentID === null + ? session.parentID === undefined + : session.parentID === parentID, + ) ?? [], + }) as never + }) spyOn(client.v2.model, "default").mockImplementation( () => ok({ @@ -193,32 +198,32 @@ describe("V2 mini transport", () => { }) while (!admitted) await Bun.sleep(0) events.push({ - id: "evt_prompted", - type: "session.next.prompted", - data: { - timestamp: 2, - sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", - }, - }) - events.push({ - id: "evt_text", - type: "session.next.text.delta", - data: { - timestamp: 3, - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - textID: "txt_1", - delta: "answer", - }, - }) - events.push({ - id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 4, sessionID: "ses_1", outcome: "success" }, - }) + id: "evt_prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + inputID: "msg_prompt", + }, + }) + events.push({ + id: "evt_text", + created: 0, + type: "text.delta", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) await turn expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"]) @@ -245,9 +250,7 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.v2.session, "prompt").mockImplementation((input) => { @@ -255,19 +258,19 @@ describe("V2 mini transport", () => { queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -341,9 +344,7 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.v2.session, "prompt").mockImplementation((input) => { @@ -351,19 +352,19 @@ describe("V2 mini transport", () => { queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -440,9 +441,7 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.v2.session, "prompt").mockImplementation((input) => { @@ -450,19 +449,19 @@ describe("V2 mini transport", () => { queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -523,6 +522,7 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_permission", + created: 0, type: "permission.v2.asked", data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] }, }) @@ -593,7 +593,9 @@ describe("V2 mini transport", () => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -663,7 +665,9 @@ describe("V2 mini transport", () => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -719,9 +723,9 @@ describe("V2 mini transport", () => { const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) events.push({ id: "evt_text", - type: "session.next.text.delta", + created: 0, + type: "text.delta", data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_assistant", textID: "txt_1", @@ -804,9 +808,10 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_reasoning", - type: "session.next.reasoning.ended", + created: 0, + type: "reasoning.ended", + durable: durable("ses_1"), data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_assistant", reasoningID: "reasoning_1", @@ -841,7 +846,9 @@ describe("V2 mini transport", () => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) @@ -857,8 +864,9 @@ describe("V2 mini transport", () => { await transport.interruptActiveTurn() events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -896,7 +904,9 @@ describe("V2 mini transport", () => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -910,19 +920,19 @@ describe("V2 mini transport", () => { while (!admitted) await Bun.sleep(0) events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -952,7 +962,9 @@ describe("V2 mini transport", () => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) const controller = new AbortController() @@ -968,21 +980,21 @@ describe("V2 mini transport", () => { while (!admitted) await Bun.sleep(0) events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", + inputID: "msg_prompt", }, }) await Bun.sleep(0) controller.abort() events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -1031,15 +1043,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) transport.selectSubagent("ses_child") events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1054,9 +1066,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_text", - type: "session.next.text.delta", + created: 0, + type: "text.delta", data: { - timestamp: 3, sessionID: "ses_child", assistantMessageID: "msg_child_a", textID: "txt_child", @@ -1068,8 +1080,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 4, sessionID: "ses_child", outcome: "success" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_child", outcome: "success" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) await transport.close() @@ -1107,15 +1120,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) // Both events arrive while session.get is still in flight. events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1124,8 +1137,9 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_child", outcome: "interrupted" }, }) await Bun.sleep(0) resolveGet?.() @@ -1165,15 +1179,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) // Child event arrives first and gets buffered behind the gated session.get. events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1183,9 +1197,10 @@ describe("V2 mini transport", () => { // Parent's background subagent tool.success adopts the child mid-discovery. events.push({ id: "evt_parent_call", - type: "session.next.tool.called", + created: 0, + type: "tool.called", + durable: durable("ses_1"), data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", @@ -1196,9 +1211,10 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_parent_success", - type: "session.next.tool.success", + created: 0, + type: "tool.success", + durable: durable("ses_1", 1), data: { - timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", @@ -1210,8 +1226,9 @@ describe("V2 mini transport", () => { // The settled event arrives after adoption, so it applies directly. events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" }, + created: 0, + type: "execution.settled", + data: { sessionID: "ses_child", outcome: "interrupted" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 668a353f67..4da4dc9d13 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -9,6 +9,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessage } from "@opencode-ai/core/session/message" +function durable(sessionID: SessionID, seq = 0, version = 1) { + return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) } +} + test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") @@ -17,11 +21,12 @@ test.skip("step snapshots carry over to assistant messages", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -38,11 +43,12 @@ test.skip("step snapshots carry over to assistant messages", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.ended", + created: DateTime.makeUnsafe(0), + type: "step.ended", + durable: durable(sessionID, 1, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), finish: "stop", cost: 0, tokens: { @@ -70,11 +76,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -88,11 +95,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.text.started", + created: DateTime.makeUnsafe(0), + type: "text.started", + durable: durable(sessionID, 1), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), textID: "text-1", }, } satisfies SessionEvent.Event), @@ -101,11 +109,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.text.ended", + created: DateTime.makeUnsafe(0), + type: "text.ended", + durable: durable(sessionID, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(3), textID: "text-1", text: "hello assistant", }, @@ -126,11 +135,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -144,11 +154,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.input.started", + created: DateTime.makeUnsafe(0), + type: "tool.input.started", + durable: durable(sessionID, 1), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), callID, name: "bash", }, @@ -158,11 +169,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.called", + created: DateTime.makeUnsafe(0), + type: "tool.called", + durable: durable(sessionID, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(3), callID, tool: "bash", input: { command: "pwd" }, @@ -174,11 +186,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.success", + created: DateTime.makeUnsafe(0), + type: "tool.success", + durable: durable(sessionID, 3), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(4), callID, structured: {}, content: [{ type: "text", text: "/tmp" }], @@ -199,16 +212,16 @@ test("compaction events reduce to compaction message only when completed", () => const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const id = EventV2.ID.create() - const compactionID = SessionMessage.ID.create() + const endedID = EventV2.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id, - type: "session.next.compaction.started", + created: DateTime.makeUnsafe(0), + type: "compaction.started", + durable: durable(sessionID), data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "auto", }, } satisfies SessionEvent.Event), @@ -219,11 +232,10 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.compaction.delta", + created: DateTime.makeUnsafe(0), + type: "compaction.delta", data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), text: "hello ", }, } satisfies SessionEvent.Event), @@ -232,11 +244,10 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.compaction.delta", + created: DateTime.makeUnsafe(0), + type: "compaction.delta", data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(3), text: "summary", }, } satisfies SessionEvent.Event), @@ -244,12 +255,12 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.compaction.ended", + id: endedID, + created: DateTime.makeUnsafe(0), + type: "compaction.ended", + durable: durable(sessionID, 1), data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(4), reason: "auto", text: "final summary", recent: "recent context", @@ -259,11 +270,11 @@ test("compaction events reduce to compaction message only when completed", () => expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ - id: compactionID, + id: SessionMessage.ID.fromEvent(endedID), type: "compaction", reason: "auto", summary: "final summary", recent: "recent context", - time: { created: DateTime.makeUnsafe(4) }, + time: { created: DateTime.makeUnsafe(0) }, }) }) diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 049ca7486c..8e764daa82 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -153,96 +153,105 @@ export type Endpoint4_11Input = { export type Endpoint4_11Output = EffectValue> export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] -export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } -export type Endpoint4_12Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_12Input) => Effect.Effect - -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -export type Endpoint4_13Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_13Input) => Effect.Effect - -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_12Request = Parameters[0] +export type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_15Input) => Effect.Effect +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +export type Endpoint4_13Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +export type Endpoint4_14Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_14Input) => Effect.Effect + +type Endpoint4_15Request = Parameters[0] +export type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] +} +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_15Input) => Effect.Effect + +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_17Input) => Effect.Effect +export type Endpoint4_17Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue< +export type Endpoint4_18Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_18Input) => Effect.Effect + +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue< ReturnType >["data"] export type SessionListContextEntriesOperation = ( - input: Endpoint4_18Input, -) => Effect.Effect - -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] -} -export type Endpoint4_19Output = EffectValue> -export type SessionPutContextEntryOperation = ( input: Endpoint4_19Input, ) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -export type Endpoint4_20Output = EffectValue> -export type SessionRemoveContextEntryOperation = ( +export type Endpoint4_20Output = EffectValue> +export type SessionPutContextEntryOperation = ( input: Endpoint4_20Input, ) => Effect.Effect -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] + readonly key: Endpoint4_21Request["params"]["key"] } -export type Endpoint4_21Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_21Input) => Stream.Stream +export type Endpoint4_21Output = EffectValue> +export type SessionRemoveContextEntryOperation = ( + input: Endpoint4_21Input, +) => Effect.Effect -type Endpoint4_22Request = Parameters[0] -export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -export type Endpoint4_22Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_22Input) => Effect.Effect +type Endpoint4_22Request = Parameters[0] +export type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] +} +export type Endpoint4_22Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_22Input) => Stream.Stream -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -export type Endpoint4_23Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_23Input) => Effect.Effect +export type Endpoint4_23Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +export type Endpoint4_24Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -export type Endpoint4_24Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_24Input) => Effect.Effect +export type Endpoint4_25Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -257,6 +266,7 @@ export interface SessionApi { readonly command: SessionCommandOperation readonly skill: SessionSkillOperation readonly synthetic: SessionSyntheticOperation + readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation readonly revertStage: SessionRevertStageOperation diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts index f5a0c0c7bb..9860aa0cad 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -9,7 +9,6 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un const fields = { id: Event.ID, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Event.Seq, version: Event.Version })), location: Schema.optional(Location.Ref), } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d2114ddee9..527dcc6f61 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -349,6 +349,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.shell", "/api/session/:sessionID/shell", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Event.ID.pipe(Schema.optional), + command: Schema.String, + }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.shell", + summary: "Run shell command", + description: + "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 3d8068b89a..eb84cf500b 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -1,14 +1,14 @@ export * as Agent from "./agent.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" import { Permission } from "./permission.js" import { Provider } from "./provider.js" import { PositiveInt, statics } from "./schema.js" -const Updated = define({ type: "agent.updated", schema: {} }) +const Updated = ephemeral({ type: "agent.updated", schema: {} }) export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts index 19e2c26b9f..58545cd86b 100644 --- a/packages/schema/src/catalog.ts +++ b/packages/schema/src/catalog.ts @@ -1,6 +1,6 @@ export * as Catalog from "./catalog.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" -const Updated = define({ type: "catalog.updated", schema: {} }) +const Updated = ephemeral({ type: "catalog.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index fb88087dbd..81e37157c6 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,11 +1,11 @@ export * as Command from "./command.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" -const Updated = define({ type: "command.updated", schema: {} }) +const Updated = ephemeral({ type: "command.updated", schema: {} }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index 6b481dcfb9..8f8870f581 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -5,11 +5,8 @@ import { SessionEvent } from "./session-event.js" import { SessionV1 } from "./session-v1.js" export const SessionDurable = { - definitions: Event.durable(SessionEvent.DurableDefinitions), + definitions: Event.durableMap(SessionEvent.Definitions), schema: SessionEvent.Durable, } as const -export const Durable = Event.durable([ - ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), - ...SessionEvent.DurableDefinitions, -]) +export const Durable = Event.durableMap([...SessionV1.Event.Definitions, ...SessionEvent.Definitions]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 19fca5e79d..e17ff96bc9 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -36,8 +36,12 @@ import { VcsEvent } from "./vcs-event.js" import { WorkspaceEvent } from "./workspace-event.js" import { WorktreeEvent } from "./worktree-event.js" -const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) -const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter( + (definition) => definition.durability === "durable", +) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter( + (definition) => definition.durability === "ephemeral", +) const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 1157a2bc44..9f7c347b03 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { ascending } from "./identifier.js" import { Location } from "./location.js" -import { statics } from "./schema.js" +import { DateTimeUtcFromMillis, statics } from "./schema.js" export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( Schema.brand("Event.ID"), @@ -24,50 +24,72 @@ export type Seq = typeof Seq.Type export const Version = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).pipe(Schema.brand("Event.Version")) export type Version = typeof Version.Type -export type Definition< +const DurableEnvelope = Schema.Struct({ aggregateID: Schema.String, seq: Seq, version: Version }) +export type DurableEnvelope = typeof DurableEnvelope.Type + +export type DurableDefinition< Type extends string = string, DataSchema extends Schema.Codec = Schema.Codec, > = Schema.Top & { readonly type: Type - readonly durable?: { + readonly durability: "durable" + readonly durable: { readonly version: number readonly aggregate: string } readonly data: DataSchema } +export type EphemeralDefinition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durability: "ephemeral" + readonly durable?: never + readonly data: DataSchema +} + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = DurableDefinition | EphemeralDefinition + export type Data = Schema.Schema.Type -export type Payload = { +type PayloadBase = { readonly id: ID readonly type: D["type"] + readonly created: typeof DateTimeUtcFromMillis.Type readonly data: Data - readonly durable?: { - readonly aggregateID: string - readonly seq: Seq - readonly version: Version - } readonly location?: Location.Ref readonly metadata?: Record } -export function define< - const Type extends string, - const Fields extends Readonly>>, ->(input: { +export type Payload = D extends DurableDefinition + ? PayloadBase & { readonly durable: DurableEnvelope } + : PayloadBase & { readonly durable?: never } + +type Input>>> = { readonly type: Type readonly durable?: { readonly version: number readonly aggregate: string } readonly schema: Fields -}) { +} + +export function durable< + const Type extends string, + const Fields extends Readonly>>, +>(input: Input & { readonly durable: NonNullable["durable"]> }) { const data = Schema.Struct(input.schema) return Schema.Struct({ id: ID, + created: DateTimeUtcFromMillis, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Seq, version: Version })), + durable: DurableEnvelope, location: optional(Location.Ref), data, }) @@ -75,10 +97,35 @@ export function define< .pipe( statics(() => ({ type: input.type, - ...(input.durable === undefined ? {} : { durable: input.durable }), + durability: "durable" as const, + durable: input.durable, data, })), - ) satisfies Definition + ) satisfies DurableDefinition +} + +export function ephemeral< + const Type extends string, + const Fields extends Readonly>>, +>(input: Omit, "durable">) { + const data = Schema.Struct(input.schema) + return Schema.Struct({ + id: ID, + created: DateTimeUtcFromMillis, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + durability: "ephemeral" as const, + durable: undefined, + data, + })), + ) satisfies EphemeralDefinition } export function inventory>(...definitions: Definitions) { @@ -107,15 +154,15 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export function durable>(definitions: Definitions) { +export function durableMap>(definitions: Definitions) { return readonlyMap( definitions.reduce((result, definition) => { - if (!definition.durable) return result + if (definition.durability !== "durable") return result const key = versionedType(definition.type, definition.durable.version) if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) result.set(key, definition) return result - }, new Map()), + }, new Map()), ) } diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts index e1e1d557d6..debe0914d1 100644 --- a/packages/schema/src/filesystem-watcher.ts +++ b/packages/schema/src/filesystem-watcher.ts @@ -1,9 +1,9 @@ export * as FileSystemWatcher from "./filesystem-watcher.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" -const Updated = define({ +const Updated = ephemeral({ type: "file.watcher.updated", schema: { file: Schema.String, diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 882c3f0abf..3599e48c7c 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -2,10 +2,10 @@ export * as FileSystem from "./filesystem.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -const Edited = define({ +const Edited = ephemeral({ type: "file.edited", schema: { file: Schema.String }, }) diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index 820192d06f..df9d455f13 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -1,7 +1,7 @@ export * as Form from "./form.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, optional, statics } from "./schema.js" @@ -127,9 +127,11 @@ export interface UrlInfo extends Schema.Schema.Type {} export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode")) export type Info = FormInfo | UrlInfo -export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({ - identifier: "Form.Value", -}) +export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate( + { + identifier: "Form.Value", + }, +) export type Value = typeof Value.Type export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" }) @@ -149,8 +151,8 @@ export const Reply = Schema.Struct({ }).annotate({ identifier: "Form.Reply" }) export interface Reply extends Schema.Schema.Type {} -const Created = define({ type: "form.created", schema: { form: Info } }) -const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } }) -const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } }) +const Created = ephemeral({ type: "form.created", schema: { form: Info } }) +const Replied = ephemeral({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } }) +const Cancelled = ephemeral({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } }) export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) } diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts index ec01d9fa36..dd88975b38 100644 --- a/packages/schema/src/ide-event.ts +++ b/packages/schema/src/ide-event.ts @@ -3,7 +3,7 @@ export * as IdeEvent from "./ide-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const Installed = Event.define({ +export const Installed = Event.ephemeral({ type: "ide.installed", schema: { ide: Schema.String, diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts index dde53f7b72..f36a2dfc14 100644 --- a/packages/schema/src/installation-event.ts +++ b/packages/schema/src/installation-event.ts @@ -3,14 +3,14 @@ export * as InstallationEvent from "./installation-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const Updated = Event.define({ +export const Updated = Event.ephemeral({ type: "installation.updated", schema: { version: Schema.String, }, }) -export const UpdateAvailable = Event.define({ +export const UpdateAvailable = Event.ephemeral({ type: "installation.update-available", schema: { version: Schema.String, diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index f1d9c63784..329bf8207f 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -2,7 +2,7 @@ export * as Integration from "./integration.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { Connection } from "./connection.js" import { ascending } from "./identifier.js" import { statics } from "./schema.js" @@ -76,11 +76,11 @@ export type Method = typeof Method.Type export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) export type Inputs = typeof Inputs.Type -const Updated = define({ +const Updated = ephemeral({ type: "integration.updated", schema: {}, }) -const ConnectionUpdated = define({ +const ConnectionUpdated = ephemeral({ type: "integration.connection.updated", schema: { integrationID: ID }, }) diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts index 68f31fbc6c..7928fa00a0 100644 --- a/packages/schema/src/lsp-event.ts +++ b/packages/schema/src/lsp-event.ts @@ -2,6 +2,6 @@ export * as LspEvent from "./lsp-event.js" import { Event } from "./event.js" -export const Updated = Event.define({ type: "lsp.updated", schema: {} }) +export const Updated = Event.ephemeral({ type: "lsp.updated", schema: {} }) export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 70be7a91d8..ae1e82656d 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -3,14 +3,14 @@ export * as McpEvent from "./mcp-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const ToolsChanged = Event.define({ +export const ToolsChanged = Event.ephemeral({ type: "mcp.tools.changed", schema: { server: Schema.String, }, }) -export const BrowserOpenFailed = Event.define({ +export const BrowserOpenFailed = Event.ephemeral({ type: "mcp.browser.open.failed", schema: { mcpName: Schema.String, @@ -20,11 +20,11 @@ export const BrowserOpenFailed = Event.define({ // Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so // observers can refresh status without polling. -export const StatusChanged = Event.define({ +export const StatusChanged = Event.ephemeral({ type: "mcp.status.changed", schema: { server: Schema.String, }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, StatusChanged) diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts index 60d630ecfa..925639aef3 100644 --- a/packages/schema/src/models-dev.ts +++ b/packages/schema/src/models-dev.ts @@ -1,8 +1,8 @@ export * as ModelsDev from "./models-dev.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" -const Refreshed = define({ +const Refreshed = ephemeral({ type: "models-dev.refreshed", schema: {}, }) diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts index de86fd1b1e..ca79081eda 100644 --- a/packages/schema/src/permission.ts +++ b/packages/schema/src/permission.ts @@ -2,7 +2,7 @@ export * as Permission from "./permission.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { SessionID } from "./session-id.js" import { statics } from "./schema.js" @@ -40,8 +40,8 @@ export interface Request extends Schema.Schema.Type {} export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) export type Reply = typeof Reply.Type -const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "permission.v2.replied", schema: { sessionID: SessionID, diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 3a9b8b55b1..40379e3051 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -1,7 +1,7 @@ export * as Plugin from "./plugin.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type @@ -11,7 +11,7 @@ export const Info = Schema.Struct({ id: ID, }).annotate({ identifier: "Plugin.Info" }) -const Added = define({ +const Added = ephemeral({ type: "plugin.added", schema: { id: ID }, }) diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts index d80322287c..aa5d51f0cf 100644 --- a/packages/schema/src/project-directories.ts +++ b/packages/schema/src/project-directories.ts @@ -1,9 +1,9 @@ export * as ProjectDirectories from "./project-directories.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { Project } from "./project.js" -const Updated = define({ +const Updated = ephemeral({ type: "project.directories.updated", schema: { projectID: Project.ID }, }) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index fce8d19ebf..e8524300a9 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -1,7 +1,7 @@ export * as Project from "./project.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { AbsolutePath, NonNegativeInt, optional } from "./schema.js" import { ProjectID } from "./project-id.js" @@ -56,5 +56,5 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Project" }) export interface Info extends Schema.Schema.Type {} -const Updated = define({ type: "project.updated", schema: Info.fields }) +const Updated = ephemeral({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts index 58dc9cb833..b2da9093f0 100644 --- a/packages/schema/src/pty.ts +++ b/packages/schema/src/pty.ts @@ -2,7 +2,7 @@ export * as Pty from "./pty.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, PositiveInt, statics } from "./schema.js" @@ -31,10 +31,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Pty" }) export interface Info extends Schema.Schema.Type {} -const Created = define({ type: "pty.created", schema: { info: Info } }) -const Updated = define({ type: "pty.updated", schema: { info: Info } }) -const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) -const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +const Created = ephemeral({ type: "pty.created", schema: { info: Info } }) +const Updated = ephemeral({ type: "pty.updated", schema: { info: Info } }) +const Exited = ephemeral({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = ephemeral({ type: "pty.deleted", schema: { id: ID } }) export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } export const CreateInput = Schema.Struct({ diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts index 56fa7a277b..617ad2a895 100644 --- a/packages/schema/src/question.ts +++ b/packages/schema/src/question.ts @@ -2,7 +2,7 @@ export * as Question from "./question.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { SessionID } from "./session-id.js" import { statics } from "./schema.js" @@ -67,8 +67,8 @@ export const Reply = Schema.Struct({ }).annotate({ identifier: "QuestionV2.Reply" }) export interface Reply extends Schema.Schema.Type {} -const Asked = define({ type: "question.v2.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "question.v2.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "question.v2.replied", schema: { sessionID: SessionID, @@ -76,7 +76,7 @@ const Replied = define({ answers: Schema.Array(Answer), }, }) -const Rejected = define({ +const Rejected = ephemeral({ type: "question.v2.rejected", schema: { sessionID: SessionID, diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index 5d63bea9df..9dd277f62b 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -2,10 +2,10 @@ export * as Reference from "./reference.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { AbsolutePath } from "./schema.js" -const Updated = define({ type: "reference.updated", schema: {} }) +const Updated = ephemeral({ type: "reference.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface LocalSource extends Schema.Schema.Type {} diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts index ec8599ad32..4f4963cfbf 100644 --- a/packages/schema/src/server-event.ts +++ b/packages/schema/src/server-event.ts @@ -2,7 +2,7 @@ export * as ServerEvent from "./server-event.js" import { Event } from "./event.js" -export const Connected = Event.define({ type: "server.connected", schema: {} }) -export const Disposed = Event.define({ type: "global.disposed", schema: {} }) +export const Connected = Event.ephemeral({ type: "server.connected", schema: {} }) +export const Disposed = Event.ephemeral({ type: "global.disposed", schema: {} }) export const Definitions = Event.inventory(Connected, Disposed) diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts index 56782fd5d5..a9c0144c98 100644 --- a/packages/schema/src/session-compaction-event.ts +++ b/packages/schema/src/session-compaction-event.ts @@ -3,7 +3,7 @@ export * as SessionCompactionEvent from "./session-compaction-event.js" import { Event } from "./event.js" import { SessionID } from "./session-id.js" -export const Compacted = Event.define({ +export const Compacted = Event.ephemeral({ type: "session.compacted", schema: { sessionID: SessionID, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index df78fa1493..fce091545a 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -6,7 +6,7 @@ import { Event } from "./event.js" import { ProviderMetadata, ToolContent } from "./llm.js" import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" -import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema.js" +import { NonNegativeInt, RelativePath } from "./schema.js" import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" @@ -20,17 +20,16 @@ export const Source = Schema.Struct({ end: NonNegativeInt, text: Schema.String, }).annotate({ - identifier: "session.next.event.source", + identifier: "session.event.source", }) export interface Source extends Schema.Schema.Type {} const Base = { - timestamp: DateTimeUtcFromMillis, sessionID: SessionID, } const PromptFields = { ...Base, - messageID: SessionMessage.ID, + inputID: SessionMessage.ID, prompt: Prompt, delivery: Delivery, } @@ -44,48 +43,46 @@ const options = { const stepSettlementOptions = { durable: { aggregate: "sessionID", - version: 2, + version: 1, }, } as const export const UnknownError = SessionMessage.UnknownError export type UnknownError = SessionMessage.UnknownError -export const AgentSwitched = Event.define({ - type: "session.next.agent.switched", +export const AgentSelected = Event.durable({ + type: "agent.selected", ...options, schema: { ...Base, - messageID: SessionMessage.ID, agent: Schema.String, }, }) -export type AgentSwitched = typeof AgentSwitched.Type +export type AgentSelected = typeof AgentSelected.Type -export const ModelSwitched = Event.define({ - type: "session.next.model.switched", +export const ModelSelected = Event.durable({ + type: "model.selected", ...options, schema: { ...Base, - messageID: SessionMessage.ID, model: Model.Ref, }, }) -export type ModelSwitched = typeof ModelSwitched.Type +export type ModelSelected = typeof ModelSelected.Type -export const Moved = Event.define({ - type: "session.next.moved", +export const Moved = Event.durable({ + type: "session.moved", ...options, schema: { ...Base, location: Location.Ref, - subdirectory: RelativePath.pipe(optional), + subpath: RelativePath.pipe(optional), }, }) export type Moved = typeof Moved.Type -export const Renamed = Event.define({ - type: "session.next.renamed", +export const Renamed = Event.durable({ + type: "renamed", ...options, schema: { ...Base, @@ -94,33 +91,36 @@ export const Renamed = Event.define({ }) export type Renamed = typeof Renamed.Type -export const Forked = Event.define({ - type: "session.next.forked", +export const Forked = Event.durable({ + type: "forked", ...options, schema: { ...Base, parentID: SessionID, - messageID: SessionMessage.ID.pipe(optional), + from: SessionMessage.ID.pipe(optional), }, }) export type Forked = typeof Forked.Type -export const Prompted = Event.define({ - type: "session.next.prompted", +export const PromptPromoted = Event.durable({ + type: "prompt.promoted", ...options, - schema: PromptFields, + schema: { + sessionID: SessionID, + inputID: SessionMessage.ID, + }, }) -export type Prompted = typeof Prompted.Type +export type PromptPromoted = typeof PromptPromoted.Type -export const PromptAdmitted = Event.define({ - type: "session.next.prompt.admitted", +export const PromptAdmitted = Event.durable({ + type: "prompt.admitted", ...options, schema: PromptFields, }) export type PromptAdmitted = typeof PromptAdmitted.Type -export const ExecutionSettled = Event.define({ - type: "session.next.execution.settled", +export const ExecutionSettled = Event.ephemeral({ + type: "execution.settled", schema: { ...Base, outcome: Schema.Literals(["success", "failure", "interrupted"]), @@ -129,23 +129,21 @@ export const ExecutionSettled = Event.define({ }) export type ExecutionSettled = typeof ExecutionSettled.Type -export const ContextUpdated = Event.define({ - type: "session.next.context.updated", +export const ContextUpdated = Event.durable({ + type: "session.context.updated", ...options, schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, }, }) export type ContextUpdated = typeof ContextUpdated.Type -export const Synthetic = Event.define({ - type: "session.next.synthetic", +export const Synthetic = Event.durable({ + type: "synthetic", ...options, schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, description: Schema.String.pipe(optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), @@ -154,12 +152,11 @@ export const Synthetic = Event.define({ export type Synthetic = typeof Synthetic.Type export namespace Skill { - export const Activated = Event.define({ - type: "session.next.skill.activated", + export const Activated = Event.durable({ + type: "skill.activated", ...options, schema: { ...Base, - messageID: SessionMessage.ID, name: Schema.String, text: Schema.String, }, @@ -168,20 +165,19 @@ export namespace Skill { } export namespace Shell { - export const Started = Event.define({ - type: "session.next.shell.started", + export const Started = Event.durable({ + type: "shell.started", ...options, schema: { ...Base, - messageID: SessionMessage.ID, callID: Schema.String, command: Schema.String, }, }) export type Started = typeof Started.Type - export const Ended = Event.define({ - type: "session.next.shell.ended", + export const Ended = Event.durable({ + type: "shell.ended", ...options, schema: { ...Base, @@ -193,8 +189,8 @@ export namespace Shell { } export namespace Step { - export const Started = Event.define({ - type: "session.next.step.started", + export const Started = Event.durable({ + type: "step.started", ...options, schema: { ...Base, @@ -206,8 +202,8 @@ export namespace Step { }) export type Started = typeof Started.Type - export const Ended = Event.define({ - type: "session.next.step.ended", + export const Ended = Event.durable({ + type: "step.ended", ...stepSettlementOptions, schema: { ...Base, @@ -229,8 +225,8 @@ export namespace Step { }) export type Ended = typeof Ended.Type - export const Failed = Event.define({ - type: "session.next.step.failed", + export const Failed = Event.durable({ + type: "step.failed", ...stepSettlementOptions, schema: { ...Base, @@ -242,8 +238,8 @@ export namespace Step { } export namespace Text { - export const Started = Event.define({ - type: "session.next.text.started", + export const Started = Event.durable({ + type: "text.started", ...options, schema: { ...Base, @@ -254,8 +250,8 @@ export namespace Text { export type Started = typeof Started.Type // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. - export const Delta = Event.define({ - type: "session.next.text.delta", + export const Delta = Event.ephemeral({ + type: "text.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -265,8 +261,8 @@ export namespace Text { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.text.ended", + export const Ended = Event.durable({ + type: "text.ended", ...options, schema: { ...Base, @@ -279,8 +275,8 @@ export namespace Text { } export namespace Reasoning { - export const Started = Event.define({ - type: "session.next.reasoning.started", + export const Started = Event.durable({ + type: "reasoning.started", ...options, schema: { ...Base, @@ -292,8 +288,8 @@ export namespace Reasoning { export type Started = typeof Started.Type // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. - export const Delta = Event.define({ - type: "session.next.reasoning.delta", + export const Delta = Event.ephemeral({ + type: "reasoning.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -303,8 +299,8 @@ export namespace Reasoning { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.reasoning.ended", + export const Ended = Event.durable({ + type: "reasoning.ended", ...options, schema: { ...Base, @@ -325,8 +321,8 @@ export namespace Tool { } export namespace Input { - export const Started = Event.define({ - type: "session.next.tool.input.started", + export const Started = Event.durable({ + type: "tool.input.started", ...options, schema: { ...ToolBase, @@ -336,8 +332,8 @@ export namespace Tool { export type Started = typeof Started.Type // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. - export const Delta = Event.define({ - type: "session.next.tool.input.delta", + export const Delta = Event.ephemeral({ + type: "tool.input.delta", schema: { ...ToolBase, delta: Schema.String, @@ -345,8 +341,8 @@ export namespace Tool { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.tool.input.ended", + export const Ended = Event.durable({ + type: "tool.input.ended", ...options, schema: { ...ToolBase, @@ -356,8 +352,8 @@ export namespace Tool { export type Ended = typeof Ended.Type } - export const Called = Event.define({ - type: "session.next.tool.called", + export const Called = Event.durable({ + type: "tool.called", ...options, schema: { ...ToolBase, @@ -375,8 +371,8 @@ export namespace Tool { * Replayable bounded running-tool state. Tools should checkpoint semantic * transitions or at a bounded cadence, not persist every stdout/stderr chunk. */ - export const Progress = Event.define({ - type: "session.next.tool.progress", + export const Progress = Event.durable({ + type: "tool.progress", ...options, schema: { ...ToolBase, @@ -386,8 +382,8 @@ export namespace Tool { }) export type Progress = typeof Progress.Type - export const Success = Event.define({ - type: "session.next.tool.success", + export const Success = Event.durable({ + type: "tool.success", ...options, schema: { ...ToolBase, @@ -403,8 +399,8 @@ export namespace Tool { }) export type Success = typeof Success.Type - export const Failed = Event.define({ - type: "session.next.tool.failed", + export const Failed = Event.durable({ + type: "tool.failed", ...options, schema: { ...ToolBase, @@ -427,12 +423,12 @@ export const RetryError = Schema.Struct({ responseBody: Schema.String.pipe(optional), metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), }).annotate({ - identifier: "session.next.retry_error", + identifier: "session.retry.error", }) export interface RetryError extends Schema.Schema.Type {} -export const Retried = Event.define({ - type: "session.next.retried", +export const Retried = Event.durable({ + type: "retried", ...options, schema: { ...Base, @@ -443,33 +439,30 @@ export const Retried = Event.define({ export type Retried = typeof Retried.Type export namespace Compaction { - export const Started = Event.define({ - type: "session.next.compaction.started", + export const Started = Event.durable({ + type: "compaction.started", ...options, schema: { ...Base, - messageID: SessionMessage.ID, reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, }) export type Started = typeof Started.Type - export const Delta = Event.define({ - type: "session.next.compaction.delta", + export const Delta = Event.ephemeral({ + type: "compaction.delta", schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, }, }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.compaction.ended", + export const Ended = Event.durable({ + type: "compaction.ended", ...options, schema: { ...Base, - messageID: SessionMessage.ID, reason: Started.data.fields.reason, text: Schema.String, recent: Schema.String, @@ -479,60 +472,26 @@ export namespace Compaction { } export namespace RevertEvent { - export const Staged = Event.define({ - type: "session.next.revert.staged", + export const Staged = Event.durable({ + type: "revert.staged", ...options, schema: { ...Base, revert: Revert.State }, }) - export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base }) - export const Committed = Event.define({ - type: "session.next.revert.committed", + export const Cleared = Event.durable({ type: "revert.cleared", ...options, schema: Base }) + export const Committed = Event.durable({ + type: "revert.committed", ...options, schema: { ...Base, messageID: SessionMessage.ID }, }) } -export const DurableDefinitions = Event.inventory( - AgentSwitched, - ModelSwitched, - Moved, - Renamed, - Forked, - Prompted, - PromptAdmitted, - ContextUpdated, - Synthetic, - Skill.Activated, - Shell.Started, - Shell.Ended, - Step.Started, - Step.Ended, - Step.Failed, - Text.Started, - Text.Ended, - Tool.Input.Started, - Tool.Input.Ended, - Tool.Called, - Tool.Progress, - Tool.Success, - Tool.Failed, - Reasoning.Started, - Reasoning.Ended, - Retried, - Compaction.Started, - Compaction.Ended, - RevertEvent.Staged, - RevertEvent.Cleared, - RevertEvent.Committed, -) - export const Definitions = Event.inventory( - AgentSwitched, - ModelSwitched, + AgentSelected, + ModelSelected, Moved, Renamed, Forked, - Prompted, + PromptPromoted, PromptAdmitted, ExecutionSettled, ContextUpdated, @@ -565,6 +524,10 @@ export const Definitions = Event.inventory( RevertEvent.Committed, ) +export const DurableDefinitions = Event.inventory( + ...Definitions.filter((definition) => definition.durability === "durable"), +) + export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "SessionDurableEvent" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 457cf87206..349d04d06e 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -8,10 +8,14 @@ import { FileAttachment, Prompt } from "./prompt.js" import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" +import { Event } from "./event.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), - statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), + statics((schema) => ({ + create: () => schema.make("msg_" + ascending()), + fromEvent: (eventID: Event.ID) => schema.make(eventID.replace(/^evt_/, "msg_")), + })), ) export type ID = typeof ID.Type @@ -27,19 +31,19 @@ const Base = { time: Schema.Struct({ created: DateTimeUtcFromMillis }), } -export interface AgentSwitched extends Schema.Schema.Type {} -export const AgentSwitched = Schema.Struct({ +export interface AgentSelected extends Schema.Schema.Type {} +export const AgentSelected = Schema.Struct({ ...Base, type: Schema.Literal("agent-switched"), agent: Schema.String, -}).annotate({ identifier: "Session.Message.AgentSwitched" }) +}).annotate({ identifier: "Session.Message.AgentSelected" }) -export interface ModelSwitched extends Schema.Schema.Type {} -export const ModelSwitched = Schema.Struct({ +export interface ModelSelected extends Schema.Schema.Type {} +export const ModelSelected = Schema.Struct({ ...Base, type: Schema.Literal("model-switched"), model: Model.Ref, -}).annotate({ identifier: "Session.Message.ModelSwitched" }) +}).annotate({ identifier: "Session.Message.ModelSelected" }) export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ @@ -207,8 +211,8 @@ export const Compaction = Schema.Struct({ }).annotate({ identifier: "Session.Message.Compaction" }) export const Message = Schema.Union([ - AgentSwitched, - ModelSwitched, + AgentSelected, + ModelSelected, User, Synthetic, System, @@ -219,5 +223,5 @@ export const Message = Schema.Union([ ]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) -export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction export type Type = Message["type"] diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index 331c58ba1c..03e6940215 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -32,7 +32,7 @@ export const Info = Schema.Union([ ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type -export const Status = Event.define({ +export const Status = Event.ephemeral({ type: "session.status", schema: { sessionID: SessionID, @@ -41,7 +41,7 @@ export const Status = Event.define({ }) // deprecated -export const Idle = Event.define({ +export const Idle = Event.ephemeral({ type: "session.idle", schema: { sessionID: SessionID, diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts index f72abcd17e..f4d68268ff 100644 --- a/packages/schema/src/session-todo.ts +++ b/packages/schema/src/session-todo.ts @@ -1,7 +1,7 @@ export * as SessionTodo from "./session-todo.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { SessionID } from "./session-id.js" export const Info = Schema.Struct({ @@ -15,7 +15,7 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Todo" }) export interface Info extends Schema.Schema.Type {} -const Updated = define({ +const Updated = ephemeral({ type: "todo.updated", schema: { sessionID: SessionID, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index a627a15950..fbe877c974 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -2,7 +2,7 @@ export * as Shell from "./shell.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" @@ -49,9 +49,9 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Shell" }) export interface Info extends Schema.Schema.Type {} -const Created = define({ type: "shell.created", schema: { info: Info } }) -const Exited = define({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) -const Deleted = define({ type: "shell.deleted", schema: { id: ID } }) +const Created = ephemeral({ type: "shell.created", schema: { info: Info } }) +const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) +const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } }) export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) } export const CreateInput = Schema.Struct({ diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index bf0dd7aa2b..184266f2ad 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -3,7 +3,7 @@ export * as Skill from "./skill.js" import { Schema } from "effect" import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ @@ -27,7 +27,7 @@ export const Info = Schema.Struct({ content: Schema.String, }).annotate({ identifier: "SkillV2.Info" }) -const Updated = define({ type: "skill.updated", schema: {} }) +const Updated = ephemeral({ type: "skill.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface EmbeddedSource extends Schema.Schema.Type {} diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts index 0de2b6e369..ba9fd29ef8 100644 --- a/packages/schema/src/tui-event.ts +++ b/packages/schema/src/tui-event.ts @@ -8,9 +8,9 @@ import { SessionID } from "./session-id.js" const DEFAULT_TOAST_DURATION = 5000 -export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) +export const PromptAppend = Event.ephemeral({ type: "tui.prompt.append", schema: { text: Schema.String } }) -export const CommandExecute = Event.define({ +export const CommandExecute = Event.ephemeral({ type: "tui.command.execute", schema: { command: Schema.Union([ @@ -38,7 +38,7 @@ export const CommandExecute = Event.define({ }, }) -export const ToastShow = Event.define({ +export const ToastShow = Event.ephemeral({ type: "tui.toast.show", schema: { title: optional(Schema.String), @@ -50,7 +50,7 @@ export const ToastShow = Event.define({ }, }) -export const SessionSelect = Event.define({ +export const SessionSelect = Event.ephemeral({ type: "tui.session.select", schema: { sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts index 12037e104f..ac22fff5e3 100644 --- a/packages/schema/src/v1/legacy-event.ts +++ b/packages/schema/src/v1/legacy-event.ts @@ -1,11 +1,11 @@ export * as LegacyEvent from "./legacy-event.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { SessionID } from "../session-id.js" import { SessionV1 } from "./session.js" -export const CommandExecuted = define({ +export const CommandExecuted = ephemeral({ type: "command.executed", schema: { name: Schema.String, diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts index af1d0098b8..1faa46a508 100644 --- a/packages/schema/src/v1/permission.ts +++ b/packages/schema/src/v1/permission.ts @@ -1,7 +1,7 @@ export * as PermissionV1 from "./permission.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { ascending } from "../identifier.js" import { Project } from "../project.js" import { statics } from "../schema.js" @@ -58,8 +58,8 @@ export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }). }) export type ReplyInput = typeof ReplyInput.Type -const Asked = define({ type: "permission.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "permission.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "permission.replied", schema: { sessionID: SessionID, requestID: ID, reply: Reply }, }) diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts index 557646c44e..da47f37ffd 100644 --- a/packages/schema/src/v1/question.ts +++ b/packages/schema/src/v1/question.ts @@ -1,7 +1,7 @@ export * as QuestionV1 from "./question.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { ascending } from "../identifier.js" import { statics } from "../schema.js" import { SessionID } from "../session-id.js" @@ -55,9 +55,9 @@ export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).a identifier: "QuestionRejected", }) -const Asked = define({ type: "question.asked", schema: Request.fields }) -const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) -const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +const Asked = ephemeral({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = ephemeral({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = ephemeral({ type: "question.rejected", schema: Rejected.fields }) export const Event = { Asked, Replied: RepliedEvent, diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 452ba3a186..1c2827f0d8 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -1,7 +1,7 @@ export * as SessionV1 from "./session.js" import { Effect, Schema, Types } from "effect" -import { define, inventory } from "../event.js" +import { durable, ephemeral, inventory } from "../event.js" import { FileDiff } from "../file-diff.js" import { Project } from "../project.js" import { Provider } from "../provider.js" @@ -569,7 +569,7 @@ export const SessionInfo = Schema.Struct({ export type SessionInfo = typeof SessionInfo.Type const events = { - Created: define({ + Created: durable({ type: "session.created", ...options, schema: { @@ -577,7 +577,7 @@ const events = { info: SessionInfo, }, }), - Updated: define({ + Updated: durable({ type: "session.updated", ...options, schema: { @@ -585,7 +585,7 @@ const events = { info: SessionInfo, }, }), - Deleted: define({ + Deleted: durable({ type: "session.deleted", ...options, schema: { @@ -593,7 +593,7 @@ const events = { info: SessionInfo, }, }), - MessageUpdated: define({ + MessageUpdated: durable({ type: "message.updated", ...options, schema: { @@ -601,7 +601,7 @@ const events = { info: Info, }, }), - MessageRemoved: define({ + MessageRemoved: durable({ type: "message.removed", ...options, schema: { @@ -609,7 +609,7 @@ const events = { messageID: MessageID, }, }), - PartUpdated: define({ + PartUpdated: durable({ type: "message.part.updated", ...options, schema: { @@ -618,7 +618,7 @@ const events = { time: Schema.Finite, }, }), - PartRemoved: define({ + PartRemoved: durable({ type: "message.part.removed", ...options, schema: { @@ -629,7 +629,7 @@ const events = { }), } -export const PartDelta = define({ +export const PartDelta = ephemeral({ type: "message.part.delta", schema: { sessionID: SessionID, @@ -640,7 +640,7 @@ export const PartDelta = define({ }, }) -export const Diff = define({ +export const Diff = ephemeral({ type: "session.diff", schema: { sessionID: SessionID, @@ -648,7 +648,7 @@ export const Diff = define({ }, }) -export const Error = define({ +export const Error = ephemeral({ type: "session.error", schema: { sessionID: Schema.optional(SessionID), diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts index 2428b432b4..5ece3cd844 100644 --- a/packages/schema/src/vcs-event.ts +++ b/packages/schema/src/vcs-event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -export const BranchUpdated = Event.define({ +export const BranchUpdated = Event.ephemeral({ type: "vcs.branch.updated", schema: { branch: optional(Schema.String), diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts index 6468a4410c..2b6a752ef5 100644 --- a/packages/schema/src/workspace-event.ts +++ b/packages/schema/src/workspace-event.ts @@ -10,21 +10,21 @@ export const ConnectionStatus = Schema.Struct({ }).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) export interface ConnectionStatus extends Schema.Schema.Type {} -export const Ready = Event.define({ +export const Ready = Event.ephemeral({ type: "workspace.ready", schema: { name: Schema.String, }, }) -export const Failed = Event.define({ +export const Failed = Event.ephemeral({ type: "workspace.failed", schema: { message: Schema.String, }, }) -export const Status = Event.define({ +export const Status = Event.ephemeral({ type: "workspace.status", schema: ConnectionStatus.fields, }) diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts index 2acb72fe7e..db80d794e5 100644 --- a/packages/schema/src/worktree-event.ts +++ b/packages/schema/src/worktree-event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -export const Ready = Event.define({ +export const Ready = Event.ephemeral({ type: "worktree.ready", schema: { name: Schema.String, @@ -12,7 +12,7 @@ export const Ready = Event.define({ }, }) -export const Failed = Event.define({ +export const Failed = Event.ephemeral({ type: "worktree.failed", schema: { message: Schema.String, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 452b0ac149..4fa23e75f7 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,7 +1,18 @@ import { describe, expect, test } from "bun:test" -import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js" +import { + Agent, + FileSystem, + Form, + Integration, + Permission, + Project, + Reference, + Session, + Workspace, +} from "../src/index.js" import { EventManifest } from "../src/event-manifest.js" import { IdeEvent } from "../src/ide-event.js" +import { McpEvent } from "../src/mcp-event.js" import { SessionEvent } from "../src/session-event.js" import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" @@ -9,11 +20,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(86) + expect(EventManifest.ServerDefinitions).toContain(Agent.Event.Updated) expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([ Agent.Event.Updated, ]) - expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(101) + expect(EventManifest.Definitions).toContain(Agent.Event.Updated) expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([ Agent.Event.Updated, ]) @@ -29,7 +40,9 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101) + expect(Array.from(EventManifest.Latest.keys())).toEqual( + EventManifest.Definitions.map((definition) => definition.type), + ) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) @@ -40,7 +53,7 @@ describe("public event manifest", () => { expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) expect(Workspace.Event).toBe(WorkspaceEvent) expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) - expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) @@ -51,6 +64,8 @@ describe("public event manifest", () => { expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) + expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) const sessionV1TailStart = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta) @@ -59,7 +74,56 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) - expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Durable.get("step.ended.1")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Durable.has("step.ended.2")).toBe(false) + }) + + test("derives durable definitions from explicit definition durability", () => { + expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual( + [ + "session.created.1", + "session.updated.1", + "session.deleted.1", + "message.updated.1", + "message.removed.1", + "message.part.updated.1", + "message.part.removed.1", + "agent.selected.1", + "model.selected.1", + "session.moved.1", + "renamed.1", + "forked.1", + "prompt.promoted.1", + "prompt.admitted.1", + "session.context.updated.1", + "synthetic.1", + "skill.activated.1", + "shell.started.1", + "shell.ended.1", + "step.started.1", + "step.ended.1", + "step.failed.1", + "text.started.1", + "text.ended.1", + "tool.input.started.1", + "tool.input.ended.1", + "tool.called.1", + "tool.progress.1", + "tool.success.1", + "tool.failed.1", + "reasoning.started.1", + "reasoning.ended.1", + "retried.1", + "compaction.started.1", + "compaction.ended.1", + "revert.staged.1", + "revert.cleared.1", + "revert.committed.1", + ].toSorted(), + ) + expect(SessionEvent.DurableDefinitions).toEqual( + SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), + ) + expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) }) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts index a05dca0c6e..404b49aa74 100644 --- a/packages/schema/test/event.test.ts +++ b/packages/schema/test/event.test.ts @@ -6,17 +6,17 @@ import { EventLog } from "../src/event-log.js" describe("public event schemas", () => { test("definition is pure", () => { const definitions = Event.inventory() - Event.define({ type: "test.pure", schema: { value: Schema.String } }) + Event.ephemeral({ type: "test.pure", schema: { value: Schema.String } }) expect(definitions).toEqual([]) }) test("latest selection is independent of declaration order", () => { - const historical = Event.define({ + const historical = Event.durable({ type: "test.versioned", durable: { aggregate: "id", version: 1 }, schema: { id: Schema.String }, }) - const current = Event.define({ + const current = Event.durable({ type: "test.versioned", durable: { aggregate: "id", version: 2 }, schema: { id: Schema.String, value: Schema.String }, @@ -27,13 +27,13 @@ describe("public event schemas", () => { }) test("durable definitions are indexed by type and version", () => { - const definition = Event.define({ + const definition = Event.durable({ type: "test.durable", durable: { aggregate: "id", version: 1 }, schema: { id: Schema.String }, }) - expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + expect(Event.durableMap([definition]).get("test.durable.1")).toBe(definition) }) test("synced marker encodes the captured watermark", () => { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 4737852ff4..087f689366 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -3,6 +3,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" +import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { Context, Effect, Layer, Scope } from "effect" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" @@ -12,7 +13,7 @@ export const create = Effect.fn("OpenCode.create")(function* () { const memoMap = yield* Layer.makeMemoMap const sdkPlugins = SdkPlugins.makeStore() const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, SdkPlugins.node]), [ + AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, Project.node, SdkPlugins.node]), [ [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], ]), memoMap, @@ -20,11 +21,13 @@ export const create = Effect.fn("OpenCode.create")(function* () { ) const plugins = Context.get(context, SdkPlugins.Service) const permissions = Context.get(context, PermissionSaved.Service) + const project = Context.get(context, Project.Service) const web = yield* Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( createEmbeddedRoutes(sdkPlugins).pipe( HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), Layer.provide(HttpServer.layerServices), ), { disableLogger: true, memoMap }, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 572cdd9665..5419b7e838 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -78,7 +78,7 @@ it.live( prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), }) const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( - Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.filter((event) => event.type === "prompt.promoted" && event.data.inputID === wake.id), Stream.runHead, Effect.timeout("10 seconds"), Effect.map(Option.getOrThrow), @@ -119,7 +119,7 @@ it.live( expect(page.data.some((session) => session.id === id)).toBe(true) expect(active).toEqual({ data: {}, watermarks: {} }) expect(admitted.sessionID).toBe(id) - expect(prompted.type).toBe("session.next.prompted") + expect(prompted.type).toBe("prompt.promoted") expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(contextEntries).toEqual([ { key: "deploy-target", value: "production" }, @@ -127,7 +127,7 @@ it.live( ]) expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }]) expect(context.some((message) => message.type === "model-switched")).toBe(true) - expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(event).toMatchObject({ type: "model.selected", durable: { seq: 1 } }) expect(message).toEqual(modelMessage) expect(missing.map((error) => error._tag)).toEqual([ "SessionNotFoundError", @@ -149,13 +149,13 @@ it.live( const opencode = yield* fixture.sdk.OpenCode.create() const id = sessionID(fixture) const connected = yield* Latch.make(false) - const prompted = yield* Deferred.make() + const prompted = yield* Deferred.make>() yield* opencode.events.subscribe().pipe( Stream.runForEach((event) => event.type === "server.connected" ? connected.open - : event.type === "session.next.prompted" && event.data.sessionID === id + : event.type === "prompt.promoted" && event.data.sessionID === id ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) : Effect.void, ), @@ -191,7 +191,7 @@ it.live( Stream.runForEach((notification: OpenCodeEvent) => notification.type === "server.connected" ? ready.open - : notification.type === "session.next.agent.switched" && notification.data.sessionID === id + : notification.type === "agent.selected" && notification.data.sessionID === id ? event.open : Effect.void, ) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 8fc0f363e7..d87f8bf98a 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -59,7 +59,13 @@ if (schemas) { } visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { - if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + if ( + /^(AgentSelected|ModelSelected|SessionMoved|Renamed|Forked|PromptPromoted|PromptAdmitted|ExecutionSettled|ContextUpdated|Synthetic|SkillActivated|ShellStarted|ShellEnded|StepStarted|StepEnded|StepFailed|TextStarted|TextDelta|TextEnded|ReasoningStarted|ReasoningDelta|ReasoningEnded|ToolInputStarted|ToolInputDelta|ToolInputEnded|ToolCalled|ToolProgress|ToolSuccess|ToolFailed|Retried|CompactionStarted|CompactionDelta|CompactionEnded|RevertStaged|RevertCleared|RevertCommitted)1$/.test( + name, + ) && + !reachable.has(name) + ) + delete schemas[name] } await Bun.write("./openapi.json", JSON.stringify(document)) } @@ -93,7 +99,11 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() -if (/export type SessionNext\w+1 =/.test(generatedTypes)) { +if ( + /export type (AgentSelected|ModelSelected|SessionMoved|Renamed|Forked|PromptPromoted|PromptAdmitted|ExecutionSettled|ContextUpdated|Synthetic|SkillActivated|ShellStarted|ShellEnded|StepStarted|StepEnded|StepFailed|TextStarted|TextDelta|TextEnded|ReasoningStarted|ReasoningDelta|ReasoningEnded|ToolInputStarted|ToolInputDelta|ToolInputEnded|ToolCalled|ToolProgress|ToolSuccess|ToolFailed|Retried|CompactionStarted|CompactionDelta|CompactionEnded|RevertStaged|RevertCleared|RevertCommitted)1 =/.test( + generatedTypes, + ) +) { throw new Error("Session history generated duplicate Session event variants") } const logTypesPatched = generatedTypes.replace( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1c896d80a3..ae8318d78e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -422,6 +422,8 @@ import type { V2SessionRevertCommitResponses, V2SessionRevertStageErrors, V2SessionRevertStageResponses, + V2SessionShellErrors, + V2SessionShellResponses, V2SessionSkillErrors, V2SessionSkillResponses, V2SessionSwitchAgentErrors, @@ -6240,6 +6242,43 @@ export class Session3 extends HeyApiClient { }) } + /** + * Run shell command + * + * Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after. + */ + public shell( + parameters: { + sessionID: string + id?: string | null + command?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "command" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/shell", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 13fbacae2b..56cdccf087 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -17,42 +17,42 @@ export type Event = | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved - | EventSessionNextAgentSwitched - | EventSessionNextModelSwitched - | EventSessionNextMoved - | EventSessionNextRenamed - | EventSessionNextForked - | EventSessionNextPrompted - | EventSessionNextPromptAdmitted - | EventSessionNextExecutionSettled - | EventSessionNextContextUpdated - | EventSessionNextSynthetic - | EventSessionNextSkillActivated - | EventSessionNextShellStarted - | EventSessionNextShellEnded - | EventSessionNextStepStarted - | EventSessionNextStepEnded - | EventSessionNextStepFailed - | EventSessionNextTextStarted - | EventSessionNextTextDelta - | EventSessionNextTextEnded - | EventSessionNextReasoningStarted - | EventSessionNextReasoningDelta - | EventSessionNextReasoningEnded - | EventSessionNextToolInputStarted - | EventSessionNextToolInputDelta - | EventSessionNextToolInputEnded - | EventSessionNextToolCalled - | EventSessionNextToolProgress - | EventSessionNextToolSuccess - | EventSessionNextToolFailed - | EventSessionNextRetried - | EventSessionNextCompactionStarted - | EventSessionNextCompactionDelta - | EventSessionNextCompactionEnded - | EventSessionNextRevertStaged - | EventSessionNextRevertCleared - | EventSessionNextRevertCommitted + | EventAgentSelected + | EventModelSelected + | EventSessionMoved + | EventRenamed + | EventForked + | EventPromptPromoted + | EventPromptAdmitted + | EventExecutionSettled + | EventSessionContextUpdated + | EventSynthetic + | EventSkillActivated + | EventShellStarted + | EventShellEnded + | EventStepStarted + | EventStepEnded + | EventStepFailed + | EventTextStarted + | EventTextDelta + | EventTextEnded + | EventReasoningStarted + | EventReasoningDelta + | EventReasoningEnded + | EventToolInputStarted + | EventToolInputDelta + | EventToolInputEnded + | EventToolCalled + | EventToolProgress + | EventToolSuccess + | EventToolFailed + | EventRetried + | EventCompactionStarted + | EventCompactionDelta + | EventCompactionEnded + | EventRevertStaged + | EventRevertCleared + | EventRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError @@ -89,7 +89,6 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged - | EventMcpBrowserOpenFailed | EventMcpStatusChanged | EventCommandExecuted | EventProjectUpdated @@ -859,80 +858,68 @@ export type GlobalEvent = { } | { id: string - type: "session.next.agent.switched" + type: "agent.selected" properties: { - timestamp: number sessionID: string - messageID: string agent: string } } | { id: string - type: "session.next.model.switched" + type: "model.selected" properties: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } | { id: string - type: "session.next.moved" + type: "session.moved" properties: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } | { id: string - type: "session.next.renamed" + type: "renamed" properties: { - timestamp: number sessionID: string title: string } } | { id: string - type: "session.next.forked" + type: "forked" properties: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } | { id: string - type: "session.next.prompted" + type: "prompt.promoted" properties: { - timestamp: number sessionID: string - messageID: string + inputID: string + } + } + | { + id: string + type: "prompt.admitted" + properties: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } | { id: string - type: "session.next.prompt.admitted" + type: "execution.settled" properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } - } - | { - id: string - type: "session.next.execution.settled" - properties: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown @@ -940,21 +927,17 @@ export type GlobalEvent = { } | { id: string - type: "session.next.context.updated" + type: "session.context.updated" properties: { - timestamp: number sessionID: string - messageID: string text: string } } | { id: string - type: "session.next.synthetic" + type: "synthetic" properties: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -964,31 +947,26 @@ export type GlobalEvent = { } | { id: string - type: "session.next.skill.activated" + type: "skill.activated" properties: { - timestamp: number sessionID: string - messageID: string name: string text: string } } | { id: string - type: "session.next.shell.started" + type: "shell.started" properties: { - timestamp: number sessionID: string - messageID: string callID: string command: string } } | { id: string - type: "session.next.shell.ended" + type: "shell.ended" properties: { - timestamp: number sessionID: string callID: string output: string @@ -996,9 +974,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.step.started" + type: "step.started" properties: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -1008,9 +985,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.step.ended" + type: "step.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -1030,9 +1006,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.step.failed" + type: "step.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown @@ -1040,9 +1015,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.started" + type: "text.started" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1050,9 +1024,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.delta" + type: "text.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1061,9 +1034,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.ended" + type: "text.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1072,9 +1044,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.started" + type: "reasoning.started" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1083,9 +1054,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.delta" + type: "reasoning.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1094,9 +1064,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.ended" + type: "reasoning.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1106,9 +1075,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.started" + type: "tool.input.started" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1117,9 +1085,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.delta" + type: "tool.input.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1128,9 +1095,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.ended" + type: "tool.input.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1139,9 +1105,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.called" + type: "tool.called" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1157,9 +1122,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.progress" + type: "tool.progress" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1171,9 +1135,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.success" + type: "tool.success" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1191,9 +1154,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.failed" + type: "tool.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1207,41 +1169,34 @@ export type GlobalEvent = { } | { id: string - type: "session.next.retried" + type: "retried" properties: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } | { id: string - type: "session.next.compaction.started" + type: "compaction.started" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } | { id: string - type: "session.next.compaction.delta" + type: "compaction.delta" properties: { - timestamp: number sessionID: string - messageID: string text: string } } | { id: string - type: "session.next.compaction.ended" + type: "compaction.ended" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string @@ -1249,26 +1204,23 @@ export type GlobalEvent = { } | { id: string - type: "session.next.revert.staged" + type: "revert.staged" properties: { - timestamp: number sessionID: string revert: RevertState } } | { id: string - type: "session.next.revert.cleared" + type: "revert.cleared" properties: { - timestamp: number sessionID: string } } | { id: string - type: "session.next.revert.committed" + type: "revert.committed" properties: { - timestamp: number sessionID: string messageID: string } @@ -1603,14 +1555,6 @@ export type GlobalEvent = { server: string } } - | { - id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } - } | { id: string type: "mcp.status.changed" @@ -1760,37 +1704,37 @@ export type GlobalEvent = { | SyncEventMessageRemoved | SyncEventMessagePartUpdated | SyncEventMessagePartRemoved - | SyncEventSessionNextAgentSwitched - | SyncEventSessionNextModelSwitched - | SyncEventSessionNextMoved - | SyncEventSessionNextRenamed - | SyncEventSessionNextForked - | SyncEventSessionNextPrompted - | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextContextUpdated - | SyncEventSessionNextSynthetic - | SyncEventSessionNextSkillActivated - | SyncEventSessionNextShellStarted - | SyncEventSessionNextShellEnded - | SyncEventSessionNextStepStarted - | SyncEventSessionNextStepEnded - | SyncEventSessionNextStepFailed - | SyncEventSessionNextTextStarted - | SyncEventSessionNextTextEnded - | SyncEventSessionNextReasoningStarted - | SyncEventSessionNextReasoningEnded - | SyncEventSessionNextToolInputStarted - | SyncEventSessionNextToolInputEnded - | SyncEventSessionNextToolCalled - | SyncEventSessionNextToolProgress - | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolFailed - | SyncEventSessionNextRetried - | SyncEventSessionNextCompactionStarted - | SyncEventSessionNextCompactionEnded - | SyncEventSessionNextRevertStaged - | SyncEventSessionNextRevertCleared - | SyncEventSessionNextRevertCommitted + | SyncEventAgentSelected + | SyncEventModelSelected + | SyncEventSessionMoved + | SyncEventRenamed + | SyncEventForked + | SyncEventPromptPromoted + | SyncEventPromptAdmitted + | SyncEventSessionContextUpdated + | SyncEventSynthetic + | SyncEventSkillActivated + | SyncEventShellStarted + | SyncEventShellEnded + | SyncEventStepStarted + | SyncEventStepEnded + | SyncEventStepFailed + | SyncEventTextStarted + | SyncEventTextEnded + | SyncEventReasoningStarted + | SyncEventReasoningEnded + | SyncEventToolInputStarted + | SyncEventToolInputEnded + | SyncEventToolCalled + | SyncEventToolProgress + | SyncEventToolSuccess + | SyncEventToolFailed + | SyncEventRetried + | SyncEventCompactionStarted + | SyncEventCompactionEnded + | SyncEventRevertStaged + | SyncEventRevertCleared + | SyncEventRevertCommitted } /** @@ -2910,38 +2854,120 @@ export type UnknownError1 = { ref?: string } +export type Renamed = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "renamed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + title: string + } +} + +export type Forked = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "forked" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + parentID: string + from?: string + } +} + +export type Synthetic = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "synthetic" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + text: string + description?: string + metadata?: { + [key: string]: unknown + } + } +} + +export type Retried = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "retried" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + attempt: number + error: SessionRetryError + } +} + export type SessionDurableEvent = - | SessionNextAgentSwitched - | SessionNextModelSwitched - | SessionNextMoved - | SessionNextRenamed - | SessionNextForked - | SessionNextPrompted - | SessionNextPromptAdmitted - | SessionNextContextUpdated - | SessionNextSynthetic - | SessionNextSkillActivated - | SessionNextShellStarted - | SessionNextShellEnded - | SessionNextStepStarted - | SessionNextStepEnded - | SessionNextStepFailed - | SessionNextTextStarted - | SessionNextTextEnded - | SessionNextToolInputStarted - | SessionNextToolInputEnded - | SessionNextToolCalled - | SessionNextToolProgress - | SessionNextToolSuccess - | SessionNextToolFailed - | SessionNextReasoningStarted - | SessionNextReasoningEnded - | SessionNextRetried - | SessionNextCompactionStarted - | SessionNextCompactionEnded - | SessionNextRevertStaged - | SessionNextRevertCleared - | SessionNextRevertCommitted + | AgentSelected + | ModelSelected + | SessionMoved + | Renamed + | Forked + | PromptPromoted + | PromptAdmitted + | SessionContextUpdated + | Synthetic + | SkillActivated + | ShellStarted + | ShellEnded + | StepStarted + | StepEnded + | StepFailed + | TextStarted + | TextEnded + | ReasoningStarted + | ReasoningEnded + | ToolInputStarted + | ToolInputEnded + | ToolCalled + | ToolProgress + | ToolSuccess + | ToolFailed + | Retried + | CompactionStarted + | CompactionEnded + | RevertStaged + | RevertCleared + | RevertCommitted export type SessionLogItem = SessionDurableEvent | EventLogSynced @@ -3016,15 +3042,11 @@ export type Shell1 = { export type SessionStatus2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3034,15 +3056,11 @@ export type SessionStatus2 = { export type QuestionReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3053,15 +3071,11 @@ export type QuestionReplied2 = { export type QuestionRejected2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3082,42 +3096,42 @@ export type V2Event = | MessageRemoved | MessagePartUpdated | MessagePartRemoved - | SessionNextAgentSwitched - | SessionNextModelSwitched - | SessionNextMoved - | SessionNextRenamed - | SessionNextForked - | SessionNextPrompted - | SessionNextPromptAdmitted - | SessionNextExecutionSettled - | SessionNextContextUpdated - | SessionNextSynthetic - | SessionNextSkillActivated - | SessionNextShellStarted - | SessionNextShellEnded - | SessionNextStepStarted - | SessionNextStepEnded - | SessionNextStepFailed - | SessionNextTextStarted - | SessionNextTextDelta - | SessionNextTextEnded - | SessionNextReasoningStarted - | SessionNextReasoningDelta - | SessionNextReasoningEnded - | SessionNextToolInputStarted - | SessionNextToolInputDelta - | SessionNextToolInputEnded - | SessionNextToolCalled - | SessionNextToolProgress - | SessionNextToolSuccess - | SessionNextToolFailed - | SessionNextRetried - | SessionNextCompactionStarted - | SessionNextCompactionDelta - | SessionNextCompactionEnded - | SessionNextRevertStaged - | SessionNextRevertCleared - | SessionNextRevertCommitted + | AgentSelected + | ModelSelected + | SessionMoved + | Renamed + | Forked + | PromptPromoted + | PromptAdmitted + | ExecutionSettled + | SessionContextUpdated + | Synthetic + | SkillActivated + | ShellStarted + | ShellEnded + | StepStarted + | StepEnded + | StepFailed + | TextStarted + | TextDelta + | TextEnded + | ReasoningStarted + | ReasoningDelta + | ReasoningEnded + | ToolInputStarted + | ToolInputDelta + | ToolInputEnded + | ToolCalled + | ToolProgress + | ToolSuccess + | ToolFailed + | Retried + | CompactionStarted + | CompactionDelta + | CompactionEnded + | RevertStaged + | RevertCleared + | RevertCommitted | MessagePartDelta | SessionDiff | SessionError @@ -3154,7 +3168,6 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged - | McpBrowserOpenFailed | McpStatusChanged | CommandExecuted | ProjectUpdated @@ -3353,7 +3366,7 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type SessionNextRetryError = { +export type SessionRetryError = { message: string statusCode?: number isRetryable: boolean @@ -3676,155 +3689,140 @@ export type SyncEventMessagePartRemoved = { } } -export type SyncEventSessionNextAgentSwitched = { +export type SyncEventAgentSelected = { type: "sync" id: string syncEvent: { - type: "session.next.agent.switched.1" + type: "agent.selected.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string agent: string } } } -export type SyncEventSessionNextModelSwitched = { +export type SyncEventModelSelected = { type: "sync" id: string syncEvent: { - type: "session.next.model.switched.1" + type: "model.selected.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } } -export type SyncEventSessionNextMoved = { +export type SyncEventSessionMoved = { type: "sync" id: string syncEvent: { - type: "session.next.moved.1" + type: "session.moved.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } } -export type SyncEventSessionNextRenamed = { +export type SyncEventRenamed = { type: "sync" id: string syncEvent: { - type: "session.next.renamed.1" + type: "renamed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string title: string } } } -export type SyncEventSessionNextForked = { +export type SyncEventForked = { type: "sync" id: string syncEvent: { - type: "session.next.forked.1" + type: "forked.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } } -export type SyncEventSessionNextPrompted = { +export type SyncEventPromptPromoted = { type: "sync" id: string syncEvent: { - type: "session.next.prompted.1" + type: "prompt.promoted.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string + inputID: string + } + } +} + +export type SyncEventPromptAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "prompt.admitted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } } -export type SyncEventSessionNextPromptAdmitted = { +export type SyncEventSessionContextUpdated = { type: "sync" id: string syncEvent: { - type: "session.next.prompt.admitted.1" + type: "session.context.updated.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } - } -} - -export type SyncEventSessionNextContextUpdated = { - type: "sync" - id: string - syncEvent: { - type: "session.next.context.updated.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string text: string } } } -export type SyncEventSessionNextSynthetic = { +export type SyncEventSynthetic = { type: "sync" id: string syncEvent: { - type: "session.next.synthetic.1" + type: "synthetic.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -3834,52 +3832,47 @@ export type SyncEventSessionNextSynthetic = { } } -export type SyncEventSessionNextSkillActivated = { +export type SyncEventSkillActivated = { type: "sync" id: string syncEvent: { - type: "session.next.skill.activated.1" + type: "skill.activated.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string name: string text: string } } } -export type SyncEventSessionNextShellStarted = { +export type SyncEventShellStarted = { type: "sync" id: string syncEvent: { - type: "session.next.shell.started.1" + type: "shell.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string callID: string command: string } } } -export type SyncEventSessionNextShellEnded = { +export type SyncEventShellEnded = { type: "sync" id: string syncEvent: { - type: "session.next.shell.ended.1" + type: "shell.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string callID: string output: string @@ -3887,16 +3880,15 @@ export type SyncEventSessionNextShellEnded = { } } -export type SyncEventSessionNextStepStarted = { +export type SyncEventStepStarted = { type: "sync" id: string syncEvent: { - type: "session.next.step.started.1" + type: "step.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -3906,16 +3898,15 @@ export type SyncEventSessionNextStepStarted = { } } -export type SyncEventSessionNextStepEnded = { +export type SyncEventStepEnded = { type: "sync" id: string syncEvent: { - type: "session.next.step.ended.2" + type: "step.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -3935,16 +3926,15 @@ export type SyncEventSessionNextStepEnded = { } } -export type SyncEventSessionNextStepFailed = { +export type SyncEventStepFailed = { type: "sync" id: string syncEvent: { - type: "session.next.step.failed.2" + type: "step.failed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown @@ -3952,16 +3942,15 @@ export type SyncEventSessionNextStepFailed = { } } -export type SyncEventSessionNextTextStarted = { +export type SyncEventTextStarted = { type: "sync" id: string syncEvent: { - type: "session.next.text.started.1" + type: "text.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -3969,16 +3958,15 @@ export type SyncEventSessionNextTextStarted = { } } -export type SyncEventSessionNextTextEnded = { +export type SyncEventTextEnded = { type: "sync" id: string syncEvent: { - type: "session.next.text.ended.1" + type: "text.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -3987,16 +3975,15 @@ export type SyncEventSessionNextTextEnded = { } } -export type SyncEventSessionNextReasoningStarted = { +export type SyncEventReasoningStarted = { type: "sync" id: string syncEvent: { - type: "session.next.reasoning.started.1" + type: "reasoning.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -4005,16 +3992,15 @@ export type SyncEventSessionNextReasoningStarted = { } } -export type SyncEventSessionNextReasoningEnded = { +export type SyncEventReasoningEnded = { type: "sync" id: string syncEvent: { - type: "session.next.reasoning.ended.1" + type: "reasoning.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -4024,16 +4010,15 @@ export type SyncEventSessionNextReasoningEnded = { } } -export type SyncEventSessionNextToolInputStarted = { +export type SyncEventToolInputStarted = { type: "sync" id: string syncEvent: { - type: "session.next.tool.input.started.1" + type: "tool.input.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4042,16 +4027,15 @@ export type SyncEventSessionNextToolInputStarted = { } } -export type SyncEventSessionNextToolInputEnded = { +export type SyncEventToolInputEnded = { type: "sync" id: string syncEvent: { - type: "session.next.tool.input.ended.1" + type: "tool.input.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4060,16 +4044,15 @@ export type SyncEventSessionNextToolInputEnded = { } } -export type SyncEventSessionNextToolCalled = { +export type SyncEventToolCalled = { type: "sync" id: string syncEvent: { - type: "session.next.tool.called.1" + type: "tool.called.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4085,16 +4068,15 @@ export type SyncEventSessionNextToolCalled = { } } -export type SyncEventSessionNextToolProgress = { +export type SyncEventToolProgress = { type: "sync" id: string syncEvent: { - type: "session.next.tool.progress.1" + type: "tool.progress.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4106,16 +4088,15 @@ export type SyncEventSessionNextToolProgress = { } } -export type SyncEventSessionNextToolSuccess = { +export type SyncEventToolSuccess = { type: "sync" id: string syncEvent: { - type: "session.next.tool.success.1" + type: "tool.success.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4133,16 +4114,15 @@ export type SyncEventSessionNextToolSuccess = { } } -export type SyncEventSessionNextToolFailed = { +export type SyncEventToolFailed = { type: "sync" id: string syncEvent: { - type: "session.next.tool.failed.1" + type: "tool.failed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4156,52 +4136,47 @@ export type SyncEventSessionNextToolFailed = { } } -export type SyncEventSessionNextRetried = { +export type SyncEventRetried = { type: "sync" id: string syncEvent: { - type: "session.next.retried.1" + type: "retried.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } } -export type SyncEventSessionNextCompactionStarted = { +export type SyncEventCompactionStarted = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.started.1" + type: "compaction.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } } -export type SyncEventSessionNextCompactionEnded = { +export type SyncEventCompactionEnded = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.ended.1" + type: "compaction.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string @@ -4209,47 +4184,44 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type SyncEventSessionNextRevertStaged = { +export type SyncEventRevertStaged = { type: "sync" id: string syncEvent: { - type: "session.next.revert.staged.1" + type: "revert.staged.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string revert: RevertState } } } -export type SyncEventSessionNextRevertCleared = { +export type SyncEventRevertCleared = { type: "sync" id: string syncEvent: { - type: "session.next.revert.cleared.1" + type: "revert.cleared.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string } } } -export type SyncEventSessionNextRevertCommitted = { +export type SyncEventRevertCommitted = { type: "sync" id: string syncEvent: { - type: "session.next.revert.committed.1" + type: "revert.committed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string messageID: string } @@ -4390,7 +4362,7 @@ export type SessionInputAdmitted = { promotedSeq?: number } -export type SessionMessageAgentSwitched = { +export type SessionMessageAgentSelected = { id: string metadata?: { [key: string]: unknown @@ -4402,7 +4374,7 @@ export type SessionMessageAgentSwitched = { agent: string } -export type SessionMessageModelSwitched = { +export type SessionMessageModelSelected = { id: string metadata?: { [key: string]: unknown @@ -4611,8 +4583,8 @@ export type SessionMessageCompaction = { } export type SessionMessage = - | SessionMessageAgentSwitched - | SessionMessageModelSwitched + | SessionMessageAgentSelected + | SessionMessageModelSelected | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem @@ -4628,267 +4600,197 @@ export type SessionContextEntryInfo = { value: unknown } -export type SessionNextAgentSwitched = { +export type AgentSelected = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.agent.switched" - durable?: { + type: "agent.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type SessionNextModelSwitched = { +export type ModelSelected = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.model.switched" - durable?: { + type: "model.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } -export type SessionNextMoved = { +export type SessionMoved = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.moved" - durable?: { + type: "session.moved" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } -export type SessionNextRenamed = { +export type PromptPromoted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.renamed" - durable?: { + type: "prompt.promoted" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - title: string + inputID: string } } -export type SessionNextForked = { +export type PromptAdmitted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.forked" - durable?: { + type: "prompt.admitted" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - parentID: string - messageID?: string - } -} - -export type SessionNextPrompted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.prompted" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } -export type SessionNextPromptAdmitted = { +export type SessionContextUpdated = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompt.admitted" - durable?: { + type: "session.context.updated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type SessionNextContextUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.context.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string text: string } } -export type SessionNextSynthetic = { +export type SkillActivated = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.synthetic" - durable?: { + type: "skill.activated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string - text: string - description?: string - metadata?: { - [key: string]: unknown - } - } -} - -export type SessionNextSkillActivated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.skill.activated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string name: string text: string } } -export type SessionNextShellStarted = { +export type ShellStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.started" - durable?: { + type: "shell.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string callID: string command: string } } -export type SessionNextShellEnded = { +export type ShellEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.ended" - durable?: { + type: "shell.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string callID: string output: string } } -export type SessionNextStepStarted = { +export type StepStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.started" - durable?: { + type: "step.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -4897,20 +4799,20 @@ export type SessionNextStepStarted = { } } -export type SessionNextStepEnded = { +export type StepEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.ended" - durable?: { + type: "step.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -4929,60 +4831,60 @@ export type SessionNextStepEnded = { } } -export type SessionNextStepFailed = { +export type StepFailed = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.failed" - durable?: { + type: "step.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown } } -export type SessionNextTextStarted = { +export type TextStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.started" - durable?: { + type: "text.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type SessionNextTextEnded = { +export type TextEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.ended" - durable?: { + type: "text.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -4990,20 +4892,63 @@ export type SessionNextTextEnded = { } } -export type SessionNextToolInputStarted = { +export type ReasoningStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.started" - durable?: { + type: "reasoning.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type ReasoningEnded = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "reasoning.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type ToolInputStarted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "tool.input.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5011,20 +4956,20 @@ export type SessionNextToolInputStarted = { } } -export type SessionNextToolInputEnded = { +export type ToolInputEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.ended" - durable?: { + type: "tool.input.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5032,20 +4977,20 @@ export type SessionNextToolInputEnded = { } } -export type SessionNextToolCalled = { +export type ToolCalled = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.called" - durable?: { + type: "tool.called" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5060,20 +5005,20 @@ export type SessionNextToolCalled = { } } -export type SessionNextToolProgress = { +export type ToolProgress = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.progress" - durable?: { + type: "tool.progress" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5084,20 +5029,20 @@ export type SessionNextToolProgress = { } } -export type SessionNextToolSuccess = { +export type ToolSuccess = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.success" - durable?: { + type: "tool.success" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5114,20 +5059,20 @@ export type SessionNextToolSuccess = { } } -export type SessionNextToolFailed = { +export type ToolFailed = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.failed" - durable?: { + type: "tool.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5140,162 +5085,97 @@ export type SessionNextToolFailed = { } } -export type SessionNextReasoningStarted = { +export type CompactionStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.started" - durable?: { + type: "compaction.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextReasoningEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.ended" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextRetried = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.retried" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - attempt: number - error: SessionNextRetryError - } -} - -export type SessionNextCompactionStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.compaction.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string reason: "auto" | "manual" } } -export type SessionNextCompactionEnded = { +export type CompactionEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.ended" - durable?: { + type: "compaction.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type SessionNextRevertStaged = { +export type RevertStaged = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.staged" - durable?: { + type: "revert.staged" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string revert: RevertState } } -export type SessionNextRevertCleared = { +export type RevertCleared = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.cleared" - durable?: { + type: "revert.cleared" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string } } -export type SessionNextRevertCommitted = { +export type RevertCommitted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.committed" - durable?: { + type: "revert.committed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string messageID: string } @@ -5630,15 +5510,11 @@ export type SkillV2Info = { export type ModelsDevRefreshed = { id: string + created: number metadata?: { [key: string]: unknown } type: "models-dev.refreshed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5647,15 +5523,11 @@ export type ModelsDevRefreshed = { export type IntegrationUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5664,15 +5536,11 @@ export type IntegrationUpdated = { export type IntegrationConnectionUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.connection.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { integrationID: string @@ -5681,15 +5549,11 @@ export type IntegrationConnectionUpdated = { export type CatalogUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "catalog.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5698,15 +5562,11 @@ export type CatalogUpdated = { export type AgentUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "agent.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5715,11 +5575,12 @@ export type AgentUpdated = { export type SessionCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.created" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5733,11 +5594,12 @@ export type SessionCreated = { export type SessionUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5751,11 +5613,12 @@ export type SessionUpdated = { export type SessionDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.deleted" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5769,11 +5632,12 @@ export type SessionDeleted = { export type MessageUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5787,11 +5651,12 @@ export type MessageUpdated = { export type MessageRemoved = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5805,11 +5670,12 @@ export type MessageRemoved = { export type MessagePartUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5824,11 +5690,12 @@ export type MessagePartUpdated = { export type MessagePartRemoved = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5841,40 +5708,30 @@ export type MessagePartRemoved = { } } -export type SessionNextExecutionSettled = { +export type ExecutionSettled = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.execution.settled" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "execution.settled" location?: LocationRef data: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown } } -export type SessionNextTextDelta = { +export type TextDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "text.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -5882,20 +5739,15 @@ export type SessionNextTextDelta = { } } -export type SessionNextReasoningDelta = { +export type ReasoningDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "reasoning.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -5903,20 +5755,15 @@ export type SessionNextReasoningDelta = { } } -export type SessionNextToolInputDelta = { +export type ToolInputDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "tool.input.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5924,37 +5771,27 @@ export type SessionNextToolInputDelta = { } } -export type SessionNextCompactionDelta = { +export type CompactionDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "compaction.delta" location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string text: string } } export type MessagePartDelta = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.delta" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -5967,15 +5804,11 @@ export type MessagePartDelta = { export type SessionDiff = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.diff" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -5985,15 +5818,11 @@ export type SessionDiff = { export type SessionError = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.error" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID?: string @@ -6011,15 +5840,11 @@ export type SessionError = { export type InstallationUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { version: string @@ -6028,15 +5853,11 @@ export type InstallationUpdated = { export type InstallationUpdateAvailable = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { version: string @@ -6045,15 +5866,11 @@ export type InstallationUpdateAvailable = { export type FileEdited = { id: string + created: number metadata?: { [key: string]: unknown } type: "file.edited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { file: string @@ -6062,15 +5879,11 @@ export type FileEdited = { export type ReferenceUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "reference.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6079,15 +5892,11 @@ export type ReferenceUpdated = { export type PermissionV2Asked = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6104,15 +5913,11 @@ export type PermissionV2Asked = { export type PermissionV2Replied = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6123,15 +5928,11 @@ export type PermissionV2Replied = { export type PluginAdded = { id: string + created: number metadata?: { [key: string]: unknown } type: "plugin.added" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6140,15 +5941,11 @@ export type PluginAdded = { export type ProjectDirectoriesUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.directories.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { projectID: string @@ -6157,15 +5954,11 @@ export type ProjectDirectoriesUpdated = { export type CommandUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6174,15 +5967,11 @@ export type CommandUpdated = { export type SkillUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "skill.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6191,15 +5980,11 @@ export type SkillUpdated = { export type FileWatcherUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "file.watcher.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { file: string @@ -6209,15 +5994,11 @@ export type FileWatcherUpdated = { export type PtyCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Pty @@ -6226,15 +6007,11 @@ export type PtyCreated = { export type PtyUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Pty @@ -6243,15 +6020,11 @@ export type PtyUpdated = { export type PtyExited = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6261,15 +6034,11 @@ export type PtyExited = { export type PtyDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6278,15 +6047,11 @@ export type PtyDeleted = { export type ShellCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Shell1 @@ -6295,15 +6060,11 @@ export type ShellCreated = { export type ShellExited = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6314,15 +6075,11 @@ export type ShellExited = { export type ShellDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6331,15 +6088,11 @@ export type ShellDeleted = { export type QuestionV2Asked = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6354,15 +6107,11 @@ export type QuestionV2Asked = { export type QuestionV2Replied = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6373,15 +6122,11 @@ export type QuestionV2Replied = { export type QuestionV2Rejected = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6421,15 +6166,11 @@ export type FormIntegerField1 = { export type FormCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { form: FormFormInfo | FormUrlInfo @@ -6440,15 +6181,11 @@ export type FormValue1 = string | number | "NaN" | "Infinity" | "-Infinity" | bo export type FormReplied = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6459,15 +6196,11 @@ export type FormReplied = { export type FormCancelled = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.cancelled" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6477,15 +6210,11 @@ export type FormCancelled = { export type TodoUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "todo.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6495,15 +6224,11 @@ export type TodoUpdated = { export type LspUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "lsp.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6512,15 +6237,11 @@ export type LspUpdated = { export type PermissionAsked = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6540,15 +6261,11 @@ export type PermissionAsked = { export type PermissionReplied = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6559,15 +6276,11 @@ export type PermissionReplied = { export type TuiPromptAppend = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { text: string @@ -6576,15 +6289,11 @@ export type TuiPromptAppend = { export type TuiCommandExecute = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { command: @@ -6611,15 +6320,11 @@ export type TuiCommandExecute = { export type TuiToastShow = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { title?: string @@ -6631,15 +6336,11 @@ export type TuiToastShow = { export type TuiSessionSelect = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { /** @@ -6651,50 +6352,24 @@ export type TuiSessionSelect = { export type McpToolsChanged = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.tools.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { server: string } } -export type McpBrowserOpenFailed = { - id: string - metadata?: { - [key: string]: unknown - } - type: "mcp.browser.open.failed" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - mcpName: string - url: string - } -} - export type McpStatusChanged = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { server: string @@ -6703,15 +6378,11 @@ export type McpStatusChanged = { export type CommandExecuted = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.executed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6723,15 +6394,11 @@ export type CommandExecuted = { export type ProjectUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6747,15 +6414,11 @@ export type ProjectUpdated = { export type SessionIdle = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.idle" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6764,15 +6427,11 @@ export type SessionIdle = { export type QuestionAsked = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6787,15 +6446,11 @@ export type QuestionAsked = { export type SessionCompacted = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.compacted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6804,15 +6459,11 @@ export type SessionCompacted = { export type VcsBranchUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { branch?: string @@ -6821,15 +6472,11 @@ export type VcsBranchUpdated = { export type WorkspaceReady = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.ready" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6838,15 +6485,11 @@ export type WorkspaceReady = { export type WorkspaceFailed = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.failed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { message: string @@ -6855,15 +6498,11 @@ export type WorkspaceFailed = { export type WorkspaceStatus = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { workspaceID: string @@ -6873,15 +6512,11 @@ export type WorkspaceStatus = { export type WorktreeReady = { id: string + created: number metadata?: { [key: string]: unknown } type: "worktree.ready" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6891,15 +6526,11 @@ export type WorktreeReady = { export type WorktreeFailed = { id: string + created: number metadata?: { [key: string]: unknown } type: "worktree.failed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { message: string @@ -6908,15 +6539,11 @@ export type WorktreeFailed = { export type ServerConnected = { id: string + created: number metadata?: { [key: string]: unknown } type: "server.connected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6925,15 +6552,11 @@ export type ServerConnected = { export type GlobalDisposed = { id: string + created: number metadata?: { [key: string]: unknown } type: "global.disposed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -7107,113 +6730,97 @@ export type EventMessagePartRemoved = { } } -export type EventSessionNextAgentSwitched = { +export type EventAgentSelected = { id: string - type: "session.next.agent.switched" + type: "agent.selected" properties: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type EventSessionNextModelSwitched = { +export type EventModelSelected = { id: string - type: "session.next.model.switched" + type: "model.selected" properties: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } -export type EventSessionNextMoved = { +export type EventSessionMoved = { id: string - type: "session.next.moved" + type: "session.moved" properties: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } -export type EventSessionNextRenamed = { +export type EventRenamed = { id: string - type: "session.next.renamed" + type: "renamed" properties: { - timestamp: number sessionID: string title: string } } -export type EventSessionNextForked = { +export type EventForked = { id: string - type: "session.next.forked" + type: "forked" properties: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } -export type EventSessionNextPrompted = { +export type EventPromptPromoted = { id: string - type: "session.next.prompted" + type: "prompt.promoted" properties: { - timestamp: number sessionID: string - messageID: string + inputID: string + } +} + +export type EventPromptAdmitted = { + id: string + type: "prompt.admitted" + properties: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } -export type EventSessionNextPromptAdmitted = { +export type EventExecutionSettled = { id: string - type: "session.next.prompt.admitted" + type: "execution.settled" properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type EventSessionNextExecutionSettled = { - id: string - type: "session.next.execution.settled" - properties: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown } } -export type EventSessionNextContextUpdated = { +export type EventSessionContextUpdated = { id: string - type: "session.next.context.updated" + type: "session.context.updated" properties: { - timestamp: number sessionID: string - messageID: string text: string } } -export type EventSessionNextSynthetic = { +export type EventSynthetic = { id: string - type: "session.next.synthetic" + type: "synthetic" properties: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -7222,46 +6829,40 @@ export type EventSessionNextSynthetic = { } } -export type EventSessionNextSkillActivated = { +export type EventSkillActivated = { id: string - type: "session.next.skill.activated" + type: "skill.activated" properties: { - timestamp: number sessionID: string - messageID: string name: string text: string } } -export type EventSessionNextShellStarted = { +export type EventShellStarted = { id: string - type: "session.next.shell.started" + type: "shell.started" properties: { - timestamp: number sessionID: string - messageID: string callID: string command: string } } -export type EventSessionNextShellEnded = { +export type EventShellEnded = { id: string - type: "session.next.shell.ended" + type: "shell.ended" properties: { - timestamp: number sessionID: string callID: string output: string } } -export type EventSessionNextStepStarted = { +export type EventStepStarted = { id: string - type: "session.next.step.started" + type: "step.started" properties: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -7270,11 +6871,10 @@ export type EventSessionNextStepStarted = { } } -export type EventSessionNextStepEnded = { +export type EventStepEnded = { id: string - type: "session.next.step.ended" + type: "step.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -7293,33 +6893,30 @@ export type EventSessionNextStepEnded = { } } -export type EventSessionNextStepFailed = { +export type EventStepFailed = { id: string - type: "session.next.step.failed" + type: "step.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown } } -export type EventSessionNextTextStarted = { +export type EventTextStarted = { id: string - type: "session.next.text.started" + type: "text.started" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type EventSessionNextTextDelta = { +export type EventTextDelta = { id: string - type: "session.next.text.delta" + type: "text.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -7327,11 +6924,10 @@ export type EventSessionNextTextDelta = { } } -export type EventSessionNextTextEnded = { +export type EventTextEnded = { id: string - type: "session.next.text.ended" + type: "text.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -7339,11 +6935,10 @@ export type EventSessionNextTextEnded = { } } -export type EventSessionNextReasoningStarted = { +export type EventReasoningStarted = { id: string - type: "session.next.reasoning.started" + type: "reasoning.started" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7351,11 +6946,10 @@ export type EventSessionNextReasoningStarted = { } } -export type EventSessionNextReasoningDelta = { +export type EventReasoningDelta = { id: string - type: "session.next.reasoning.delta" + type: "reasoning.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7363,11 +6957,10 @@ export type EventSessionNextReasoningDelta = { } } -export type EventSessionNextReasoningEnded = { +export type EventReasoningEnded = { id: string - type: "session.next.reasoning.ended" + type: "reasoning.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7376,11 +6969,10 @@ export type EventSessionNextReasoningEnded = { } } -export type EventSessionNextToolInputStarted = { +export type EventToolInputStarted = { id: string - type: "session.next.tool.input.started" + type: "tool.input.started" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7388,11 +6980,10 @@ export type EventSessionNextToolInputStarted = { } } -export type EventSessionNextToolInputDelta = { +export type EventToolInputDelta = { id: string - type: "session.next.tool.input.delta" + type: "tool.input.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7400,11 +6991,10 @@ export type EventSessionNextToolInputDelta = { } } -export type EventSessionNextToolInputEnded = { +export type EventToolInputEnded = { id: string - type: "session.next.tool.input.ended" + type: "tool.input.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7412,11 +7002,10 @@ export type EventSessionNextToolInputEnded = { } } -export type EventSessionNextToolCalled = { +export type EventToolCalled = { id: string - type: "session.next.tool.called" + type: "tool.called" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7431,11 +7020,10 @@ export type EventSessionNextToolCalled = { } } -export type EventSessionNextToolProgress = { +export type EventToolProgress = { id: string - type: "session.next.tool.progress" + type: "tool.progress" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7446,11 +7034,10 @@ export type EventSessionNextToolProgress = { } } -export type EventSessionNextToolSuccess = { +export type EventToolSuccess = { id: string - type: "session.next.tool.success" + type: "tool.success" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7467,11 +7054,10 @@ export type EventSessionNextToolSuccess = { } } -export type EventSessionNextToolFailed = { +export type EventToolFailed = { id: string - type: "session.next.tool.failed" + type: "tool.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7484,76 +7070,66 @@ export type EventSessionNextToolFailed = { } } -export type EventSessionNextRetried = { +export type EventRetried = { id: string - type: "session.next.retried" + type: "retried" properties: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } -export type EventSessionNextCompactionStarted = { +export type EventCompactionStarted = { id: string - type: "session.next.compaction.started" + type: "compaction.started" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } -export type EventSessionNextCompactionDelta = { +export type EventCompactionDelta = { id: string - type: "session.next.compaction.delta" + type: "compaction.delta" properties: { - timestamp: number sessionID: string - messageID: string text: string } } -export type EventSessionNextCompactionEnded = { +export type EventCompactionEnded = { id: string - type: "session.next.compaction.ended" + type: "compaction.ended" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type EventSessionNextRevertStaged = { +export type EventRevertStaged = { id: string - type: "session.next.revert.staged" + type: "revert.staged" properties: { - timestamp: number sessionID: string revert: RevertState } } -export type EventSessionNextRevertCleared = { +export type EventRevertCleared = { id: string - type: "session.next.revert.cleared" + type: "revert.cleared" properties: { - timestamp: number sessionID: string } } -export type EventSessionNextRevertCommitted = { +export type EventRevertCommitted = { id: string - type: "session.next.revert.committed" + type: "revert.committed" properties: { - timestamp: number sessionID: string messageID: string } @@ -7893,15 +7469,6 @@ export type EventMcpToolsChanged = { } } -export type EventMcpBrowserOpenFailed = { - id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -8355,7 +7922,7 @@ export type UnknownErrorV2 = { ref?: string | null } -export type SessionMessageAgentSwitched2 = { +export type SessionMessageAgentSelected2 = { id: string metadata?: { [key: string]: unknown @@ -8367,7 +7934,7 @@ export type SessionMessageAgentSwitched2 = { agent: string } -export type SessionMessageModelSwitched2 = { +export type SessionMessageModelSelected2 = { id: string metadata?: { [key: string]: unknown @@ -8601,8 +8168,8 @@ export type SessionMessageCompaction2 = { } export type SessionMessage2 = - | SessionMessageAgentSwitched2 - | SessionMessageModelSwitched2 + | SessionMessageAgentSelected2 + | SessionMessageModelSelected2 | SessionMessageUser2 | SessionMessageSynthetic2 | SessionMessageSystem2 @@ -8621,183 +8188,177 @@ export type SessionContextEntryInfo2 = { value: unknown } -export type SessionNextAgentSwitched2 = { +export type AgentSelected2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.agent.switched" - durable?: { + type: "agent.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type SessionNextModelSwitched2 = { +export type ModelSelected2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.model.switched" - durable?: { + type: "model.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string model: ModelRef2 } } -export type SessionNextMoved2 = { +export type SessionMoved2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.moved" - durable?: { + type: "session.moved" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string location: LocationRef2 - subdirectory?: string + subpath?: string } } -export type SessionNextRenamed2 = { +export type RenamedV2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.renamed" - durable?: { + type: "renamed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string title: string } } -export type SessionNextForked2 = { +export type ForkedV2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.forked" - durable?: { + type: "forked" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } -export type SessionNextPrompted2 = { +export type PromptPromoted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompted" - durable?: { + type: "prompt.promoted" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string + inputID: string + } +} + +export type PromptAdmitted2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "prompt.admitted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + inputID: string prompt: PromptV2 delivery: "steer" | "queue" } } -export type SessionNextPromptAdmitted2 = { +export type SessionContextUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompt.admitted" - durable?: { + type: "session.context.updated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string - prompt: PromptV2 - delivery: "steer" | "queue" - } -} - -export type SessionNextContextUpdated2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.context.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - messageID: string text: string } } -export type SessionNextSynthetic2 = { +export type SyntheticV2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.synthetic" - durable?: { + type: "synthetic" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -8806,82 +8367,80 @@ export type SessionNextSynthetic2 = { } } -export type SessionNextSkillActivated2 = { +export type SkillActivated2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.skill.activated" - durable?: { + type: "skill.activated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string name: string text: string } } -export type SessionNextShellStarted2 = { +export type ShellStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.started" - durable?: { + type: "shell.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string callID: string command: string } } -export type SessionNextShellEnded2 = { +export type ShellEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.ended" - durable?: { + type: "shell.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string callID: string output: string } } -export type SessionNextStepStarted2 = { +export type StepStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.started" - durable?: { + type: "step.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -8890,20 +8449,20 @@ export type SessionNextStepStarted2 = { } } -export type SessionNextStepEnded2 = { +export type StepEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.ended" - durable?: { + type: "step.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -8922,60 +8481,60 @@ export type SessionNextStepEnded2 = { } } -export type SessionNextStepFailed2 = { +export type StepFailed2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.failed" - durable?: { + type: "step.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown2 } } -export type SessionNextTextStarted2 = { +export type TextStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.started" - durable?: { + type: "text.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type SessionNextTextEnded2 = { +export type TextEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.ended" - durable?: { + type: "text.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -8983,68 +8542,123 @@ export type SessionNextTextEnded2 = { } } -export type SessionNextToolInputStarted2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type SessionNextToolInputEnded2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.ended" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - export type LlmProviderMetadata3 = { [key: string]: { [key: string]: unknown } } -export type SessionNextToolCalled2 = { +export type ReasoningStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.called" - durable?: { + type: "reasoning.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata3 + } +} + +export type LlmProviderMetadata4 = { + [key: string]: { + [key: string]: unknown + } +} + +export type ReasoningEnded2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "reasoning.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata4 + } +} + +export type ToolInputStarted2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "tool.input.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type ToolInputEnded2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "tool.input.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type LlmProviderMetadata5 = { + [key: string]: { + [key: string]: unknown + } +} + +export type ToolCalled2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "tool.called" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9054,25 +8668,25 @@ export type SessionNextToolCalled2 = { } provider: { executed: boolean - metadata?: LlmProviderMetadata3 + metadata?: LlmProviderMetadata5 } } } -export type SessionNextToolProgress2 = { +export type ToolProgress2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.progress" - durable?: { + type: "tool.progress" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9083,26 +8697,26 @@ export type SessionNextToolProgress2 = { } } -export type LlmProviderMetadata4 = { +export type LlmProviderMetadata6 = { [key: string]: { [key: string]: unknown } } -export type SessionNextToolSuccess2 = { +export type ToolSuccess2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.success" - durable?: { + type: "tool.success" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9114,99 +8728,44 @@ export type SessionNextToolSuccess2 = { result?: unknown provider: { executed: boolean - metadata?: LlmProviderMetadata4 + metadata?: LlmProviderMetadata6 } } } -export type LlmProviderMetadata5 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionNextToolFailed2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.failed" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown2 - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata5 - } - } -} - -export type LlmProviderMetadata6 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionNextReasoningStarted2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata6 - } -} - export type LlmProviderMetadata7 = { [key: string]: { [key: string]: unknown } } -export type SessionNextReasoningEnded2 = { +export type ToolFailed2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.ended" - durable?: { + type: "tool.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata7 + callID: string + error: SessionErrorUnknown2 + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata7 + } } } -export type SessionNextRetryError2 = { +export type SessionRetryError2 = { message: string statusCode?: number isRetryable: boolean @@ -9219,156 +8778,154 @@ export type SessionNextRetryError2 = { } } -export type SessionNextRetried2 = { +export type RetriedV2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.retried" - durable?: { + type: "retried" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError2 + error: SessionRetryError2 } } -export type SessionNextCompactionStarted2 = { +export type CompactionStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.started" - durable?: { + type: "compaction.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } -export type SessionNextCompactionEnded2 = { +export type CompactionEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.ended" - durable?: { + type: "compaction.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type SessionNextRevertStaged2 = { +export type RevertStaged2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.staged" - durable?: { + type: "revert.staged" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string revert: RevertState2 } } -export type SessionNextRevertCleared2 = { +export type RevertCleared2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.cleared" - durable?: { + type: "revert.cleared" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string } } -export type SessionNextRevertCommitted2 = { +export type RevertCommitted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.committed" - durable?: { + type: "revert.committed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string messageID: string } } export type SessionDurableEventV2 = - | SessionNextAgentSwitched2 - | SessionNextModelSwitched2 - | SessionNextMoved2 - | SessionNextRenamed2 - | SessionNextForked2 - | SessionNextPrompted2 - | SessionNextPromptAdmitted2 - | SessionNextContextUpdated2 - | SessionNextSynthetic2 - | SessionNextSkillActivated2 - | SessionNextShellStarted2 - | SessionNextShellEnded2 - | SessionNextStepStarted2 - | SessionNextStepEnded2 - | SessionNextStepFailed2 - | SessionNextTextStarted2 - | SessionNextTextEnded2 - | SessionNextToolInputStarted2 - | SessionNextToolInputEnded2 - | SessionNextToolCalled2 - | SessionNextToolProgress2 - | SessionNextToolSuccess2 - | SessionNextToolFailed2 - | SessionNextReasoningStarted2 - | SessionNextReasoningEnded2 - | SessionNextRetried2 - | SessionNextCompactionStarted2 - | SessionNextCompactionEnded2 - | SessionNextRevertStaged2 - | SessionNextRevertCleared2 - | SessionNextRevertCommitted2 + | AgentSelected2 + | ModelSelected2 + | SessionMoved2 + | RenamedV2 + | ForkedV2 + | PromptPromoted2 + | PromptAdmitted2 + | SessionContextUpdated2 + | SyntheticV2 + | SkillActivated2 + | ShellStarted2 + | ShellEnded2 + | StepStarted2 + | StepEnded2 + | StepFailed2 + | TextStarted2 + | TextEnded2 + | ReasoningStarted2 + | ReasoningEnded2 + | ToolInputStarted2 + | ToolInputEnded2 + | ToolCalled2 + | ToolProgress2 + | ToolSuccess2 + | ToolFailed2 + | RetriedV2 + | CompactionStarted2 + | CompactionEnded2 + | RevertStaged2 + | RevertCleared2 + | RevertCommitted2 /** * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. @@ -9885,15 +9442,11 @@ export type SkillV2Info2 = { export type ModelsDevRefreshed2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "models-dev.refreshed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9904,15 +9457,11 @@ export type ModelsDevRefreshed2 = { export type IntegrationUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9923,15 +9472,11 @@ export type IntegrationUpdated2 = { export type IntegrationConnectionUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.connection.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { integrationID: string @@ -9940,15 +9485,11 @@ export type IntegrationConnectionUpdated2 = { export type CatalogUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "catalog.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9959,15 +9500,11 @@ export type CatalogUpdated2 = { export type AgentUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "agent.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10049,11 +9586,12 @@ export type SessionV2 = { export type SessionCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.created" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10067,11 +9605,12 @@ export type SessionCreated2 = { export type SessionUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10085,11 +9624,12 @@ export type SessionUpdated2 = { export type SessionDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.deleted" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10259,11 +9799,12 @@ export type MessageV2 = UserMessageV2 | AssistantMessageV2 export type MessageUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10277,11 +9818,12 @@ export type MessageUpdated2 = { export type MessageRemoved2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10558,11 +10100,12 @@ export type PartV2 = export type MessagePartUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10577,11 +10120,12 @@ export type MessagePartUpdated2 = { export type MessagePartRemoved2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10594,40 +10138,30 @@ export type MessagePartRemoved2 = { } } -export type SessionNextExecutionSettled2 = { +export type ExecutionSettled2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.execution.settled" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "execution.settled" location?: LocationRef2 data: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown2 } } -export type SessionNextTextDelta2 = { +export type TextDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "text.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -10635,20 +10169,15 @@ export type SessionNextTextDelta2 = { } } -export type SessionNextReasoningDelta2 = { +export type ReasoningDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "reasoning.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -10656,20 +10185,15 @@ export type SessionNextReasoningDelta2 = { } } -export type SessionNextToolInputDelta2 = { +export type ToolInputDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "tool.input.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -10677,37 +10201,27 @@ export type SessionNextToolInputDelta2 = { } } -export type SessionNextCompactionDelta2 = { +export type CompactionDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "compaction.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string text: string } } export type FileEdited2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "file.edited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { file: string @@ -10716,15 +10230,11 @@ export type FileEdited2 = { export type ReferenceUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "reference.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10735,15 +10245,11 @@ export type ReferenceUpdated2 = { export type PermissionV2Asked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10760,15 +10266,11 @@ export type PermissionV2Asked2 = { export type PermissionV2Replied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -10779,15 +10281,11 @@ export type PermissionV2Replied2 = { export type PluginAdded2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "plugin.added" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10796,15 +10294,11 @@ export type PluginAdded2 = { export type ProjectDirectoriesUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.directories.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { projectID: string @@ -10813,15 +10307,11 @@ export type ProjectDirectoriesUpdated2 = { export type CommandUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10832,15 +10322,11 @@ export type CommandUpdated2 = { export type SkillUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "skill.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10851,15 +10337,11 @@ export type SkillUpdated2 = { export type FileWatcherUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "file.watcher.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { file: string @@ -10880,15 +10362,11 @@ export type PtyV2 = { export type PtyCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { info: PtyV2 @@ -10897,15 +10375,11 @@ export type PtyCreated2 = { export type PtyUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { info: PtyV2 @@ -10914,15 +10388,11 @@ export type PtyUpdated2 = { export type PtyExited2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10932,15 +10402,11 @@ export type PtyExited2 = { export type PtyDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10967,15 +10433,11 @@ export type ShellV2 = { export type ShellCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { info: ShellV2 @@ -10984,15 +10446,11 @@ export type ShellCreated2 = { export type ShellExited2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11003,15 +10461,11 @@ export type ShellExited2 = { export type ShellDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11053,15 +10507,11 @@ export type QuestionV2Tool2 = { export type QuestionV2Asked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11078,15 +10528,11 @@ export type QuestionV2Answer2 = Array export type QuestionV2Replied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11097,15 +10543,11 @@ export type QuestionV2Replied2 = { export type QuestionV2Rejected2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11208,15 +10650,11 @@ export type FormUrlInfo1 = { export type FormCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { form: FormFormInfo1 | FormUrlInfo1 @@ -11231,15 +10669,11 @@ export type FormAnswer1 = { export type FormReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11250,15 +10684,11 @@ export type FormReplied2 = { export type FormCancelled2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.cancelled" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11283,15 +10713,11 @@ export type TodoV2 = { export type TodoUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "todo.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11323,15 +10749,11 @@ export type SessionStatusV2 = export type SessionStatusV22 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11341,15 +10763,11 @@ export type SessionStatusV22 = { export type SessionIdle2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.idle" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11358,15 +10776,11 @@ export type SessionIdle2 = { export type TuiPromptAppend2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { text: string @@ -11375,15 +10789,11 @@ export type TuiPromptAppend2 = { export type TuiCommandExecute2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { command: @@ -11410,15 +10820,11 @@ export type TuiCommandExecute2 = { export type TuiToastShow2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { title?: string @@ -11430,15 +10836,11 @@ export type TuiToastShow2 = { export type TuiSessionSelect2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { /** @@ -11450,15 +10852,11 @@ export type TuiSessionSelect2 = { export type InstallationUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { version: string @@ -11467,15 +10865,11 @@ export type InstallationUpdated2 = { export type InstallationUpdateAvailable2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { version: string @@ -11484,15 +10878,11 @@ export type InstallationUpdateAvailable2 = { export type VcsBranchUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { branch?: string @@ -11501,15 +10891,11 @@ export type VcsBranchUpdated2 = { export type McpStatusChanged2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { server: string @@ -11518,15 +10904,11 @@ export type McpStatusChanged2 = { export type PermissionAsked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11546,15 +10928,11 @@ export type PermissionAsked2 = { export type PermissionReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11604,15 +10982,11 @@ export type QuestionToolV2 = { export type QuestionAsked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11629,15 +11003,11 @@ export type QuestionAnswerV2 = Array export type QuestionRepliedV2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11648,15 +11018,11 @@ export type QuestionRepliedV2 = { export type QuestionRejectedV2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11666,15 +11032,11 @@ export type QuestionRejectedV2 = { export type SessionError2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.error" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID?: string | null @@ -11696,11 +11058,6 @@ export type V2EventServerConnected = { metadata?: { [key: string]: unknown } | null - durable?: { - aggregateID: string - seq: number - version: number - } | null location?: LocationRef2 | null type: "server.connected" data: @@ -11723,42 +11080,42 @@ export type V2EventV2 = | MessageRemoved2 | MessagePartUpdated2 | MessagePartRemoved2 - | SessionNextAgentSwitched2 - | SessionNextModelSwitched2 - | SessionNextMoved2 - | SessionNextRenamed2 - | SessionNextForked2 - | SessionNextPrompted2 - | SessionNextPromptAdmitted2 - | SessionNextExecutionSettled2 - | SessionNextContextUpdated2 - | SessionNextSynthetic2 - | SessionNextSkillActivated2 - | SessionNextShellStarted2 - | SessionNextShellEnded2 - | SessionNextStepStarted2 - | SessionNextStepEnded2 - | SessionNextStepFailed2 - | SessionNextTextStarted2 - | SessionNextTextDelta2 - | SessionNextTextEnded2 - | SessionNextReasoningStarted2 - | SessionNextReasoningDelta2 - | SessionNextReasoningEnded2 - | SessionNextToolInputStarted2 - | SessionNextToolInputDelta2 - | SessionNextToolInputEnded2 - | SessionNextToolCalled2 - | SessionNextToolProgress2 - | SessionNextToolSuccess2 - | SessionNextToolFailed2 - | SessionNextRetried2 - | SessionNextCompactionStarted2 - | SessionNextCompactionDelta2 - | SessionNextCompactionEnded2 - | SessionNextRevertStaged2 - | SessionNextRevertCleared2 - | SessionNextRevertCommitted2 + | AgentSelected2 + | ModelSelected2 + | SessionMoved2 + | RenamedV2 + | ForkedV2 + | PromptPromoted2 + | PromptAdmitted2 + | ExecutionSettled2 + | SessionContextUpdated2 + | SyntheticV2 + | SkillActivated2 + | ShellStarted2 + | ShellEnded2 + | StepStarted2 + | StepEnded2 + | StepFailed2 + | TextStarted2 + | TextDelta2 + | TextEnded2 + | ReasoningStarted2 + | ReasoningDelta2 + | ReasoningEnded2 + | ToolInputStarted2 + | ToolInputDelta2 + | ToolInputEnded2 + | ToolCalled2 + | ToolProgress2 + | ToolSuccess2 + | ToolFailed2 + | RetriedV2 + | CompactionStarted2 + | CompactionDelta2 + | CompactionEnded2 + | RevertStaged2 + | RevertCleared2 + | RevertCommitted2 | FileEdited2 | ReferenceUpdated2 | PermissionV2Asked2 @@ -16686,6 +16043,44 @@ export type V2SessionSyntheticResponses = { export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] +export type V2SessionShellData = { + body: { + id?: string | null + command: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/shell" +} + +export type V2SessionShellErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 + /** + * SessionNotFoundError + */ + 404: SessionNotFoundErrorV2 +} + +export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors] + +export type V2SessionShellResponses = { + /** + * + */ + 204: void +} + +export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses] + export type V2SessionCompactData = { body?: never path: { diff --git a/packages/server/package.json b/packages/server/package.json index 86e5683dcf..6c5b3cb1b5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,6 +14,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index c71536518d..239eb0d237 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -1,6 +1,7 @@ export * as ServerAuth from "./auth" -import { Config as EffectConfig, Context, Effect, Layer, Option, Redacted } from "effect" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { all, option, string, withDefault } from "effect/Config" export type Credentials = { password?: string @@ -27,9 +28,9 @@ export class Config extends Context.Service()("@opencode/ServerAut this, Effect.gen(function* () { return Config.of( - yield* EffectConfig.all({ - password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), - username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), + yield* all({ + password: string("OPENCODE_SERVER_PASSWORD").pipe(option), + username: string("OPENCODE_SERVER_USERNAME").pipe(withDefault("opencode")), }), ) }), diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 7e138a5d5a..49e84d49ba 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -8,14 +8,16 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", .handle( "credential.update", Effect.fn(function* (ctx) { - yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label }) + const integration = yield* Integration.Service + yield* integration.connection.update(ctx.params.credentialID, { label: ctx.payload.label }) return HttpApiSchema.NoContent.make() }), ) .handle( "credential.remove", Effect.fn(function* (ctx) { - yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID) + const integration = yield* Integration.Service + yield* integration.connection.remove(ctx.params.credentialID) return HttpApiSchema.NoContent.make() }), ), diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index f412137aba..ca9c02620d 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,9 +1,9 @@ import { EventV2 } from "@opencode-ai/core/event" import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" const subscriberCapacity = 256 diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index c7d1d43bab..d623f2d7e6 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -11,7 +11,8 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler return handlers .handleRaw("fs.read", (ctx) => Effect.gen(function* () { - const file = yield* (yield* FileSystem.Service).read({ + const fs = yield* FileSystem.Service + const file = yield* fs.read({ path: RelativePath.make( decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), ), diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 0425c14996..cbc49180f6 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -17,7 +17,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "permission.request.list", Effect.fn(function* () { - return yield* response((yield* PermissionV2.Service).list()) + const permission = yield* PermissionV2.Service + return yield* response(permission.list()) }), ) .handle( @@ -59,7 +60,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "session.permission.get", Effect.fn(function* (ctx) { - const request = yield* (yield* PermissionV2.Service).get(ctx.params.requestID) + const permission = yield* PermissionV2.Service + const request = yield* permission.get(ctx.params.requestID) if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) return { data: request } }), @@ -80,8 +82,9 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", "permission.saved.list", Effect.fn(function* (ctx) { const location = yield* Location.Service + const saved = yield* PermissionSaved.Service return { - data: yield* (yield* PermissionSaved.Service).list({ + data: yield* saved.list({ projectID: ctx.query.projectID ?? location.project.id, }), } @@ -90,7 +93,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "permission.saved.remove", Effect.fn(function* (ctx) { - yield* (yield* PermissionSaved.Service).remove(ctx.params.id) + const saved = yield* PermissionSaved.Service + yield* saved.remove(ctx.params.id) return HttpApiSchema.NoContent.make() }), ) diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index cda2cf43e9..7f8a9ea64e 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -5,7 +5,7 @@ import { Location } from "@opencode-ai/core/location" import { Effect, Queue } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" +import { Socket } from "effect/unstable/socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" @@ -32,7 +32,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => .handle( "pty.list", Effect.fn(function* () { - return yield* response((yield* Pty.Service).list()) + const pty = yield* Pty.Service + return yield* response(pty.list()) }), ) .handle( diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 954afe0df5..d8011dd269 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -26,13 +26,15 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han .handle( "question.request.list", Effect.fn(function* () { - return yield* response((yield* QuestionV2.Service).list()) + const question = yield* QuestionV2.Service + return yield* response(question.list()) }), ) .handle( "session.question.list", Effect.fn(function* (ctx) { - const requests = yield* (yield* QuestionV2.Service).list() + const question = yield* QuestionV2.Service + const requests = yield* question.list() return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) } }), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index f6dbabdbf8..8a3cf1b130 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -323,6 +323,24 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.shell", + Effect.fn(function* (ctx) { + yield* session + .shell({ sessionID: ctx.params.sessionID, id: ctx.payload.id, command: ctx.payload.command }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 30c3d961b7..c5e43989d0 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -6,6 +6,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Job } from "@opencode-ai/core/job" @@ -23,7 +24,7 @@ import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" -import { layer as locationLayer } from "./location" +import { layer } from "./location" import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" @@ -33,6 +34,7 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, + Project.node, SessionV2.node, PluginRuntime.providerNode, PermissionSaved.node, @@ -72,7 +74,7 @@ function makeRoutes( const serviceLayer = simulationEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements } = yield* Effect.promise(() => import("./simulation")) + const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements]) }), ) @@ -82,7 +84,7 @@ function makeRoutes( Layer.provide(handlers), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), + Layer.provide(layer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), @@ -97,4 +99,4 @@ function simulationEnabled() { export const routes = createRoutes() export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true }) + HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/server/src/simulation/index.ts b/packages/server/src/simulation/index.ts deleted file mode 100644 index 3f499a5bf8..0000000000 --- a/packages/server/src/simulation/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -/** - * Layer replacements applied when the server is built in simulation mode. - * - * Empty for now; simulation-mode implementations will populate this with - * replacement nodes/layers that swap real services for simulated ones (e.g. - * a fake filesystem). The server merges these into the app node build when - * `OPENCODE_SIMULATION` is enabled, via a dynamic import so this module is - * never loaded eagerly. - */ -export const simulationReplacements: LayerNode.Replacements = [] - -export * as Simulation from "./index" diff --git a/packages/simulation/package.json b/packages/simulation/package.json new file mode 100644 index 0000000000..29613ff28a --- /dev/null +++ b/packages/simulation/package.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./backend": "./src/backend/index.ts", + "./backend/*": "./src/backend/*.ts", + "./frontend": "./src/frontend/simulation.ts", + "./frontend/*": "./src/frontend/*.ts", + "./protocol": "./src/protocol/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts new file mode 100644 index 0000000000..10bded13af --- /dev/null +++ b/packages/simulation/src/backend/control.ts @@ -0,0 +1,111 @@ +import { Effect } from "effect" +import { SimulationProtocol } from "../protocol" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * Backend-hosted simulation control WebSocket. + * + * JSON-RPC 2.0 over a loopback WebSocket, mirroring the protocol of the TUI + * simulation server. Drivers connect directly (standalone topology; no + * frontend proxy) to answer LLM exchanges and inspect the simulated network. + * This is also the headless-simulation interface: it works with no TUI at + * all. + * + * Methods: + * - `llm.attach` -> subscribe; pending and future exchanges arrive + * as `llm.request` notifications + * - `llm.chunk` { id, items } append response items to an exchange + * - `llm.finish` { id, reason? } finish an exchange + * - `llm.pending` list open exchanges + * - `network.log` simulated network request log + */ + +const DefaultPort = 40950 +const MaxPortAttempts = 100 + +type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> + +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) +} + +async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { + switch (request.method) { + case "llm.attach": { + socket.data.unsubscribe?.() + socket.data.unsubscribe = SimulationLLMExchange.subscribe((exchange) => { + socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: exchange })) + }) + return { attached: true } + } + case "llm.chunk": { + const params = await SimulationProtocol.Backend.decodeChunkParams(request.params) + await Effect.runPromise( + SimulationLLMExchange.push( + params.id, + params.items.map((item) => ({ type: "item", item }) as const), + ), + ) + return { ok: true } + } + case "llm.finish": { + const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) + await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + return { ok: true } + } + case "llm.pending": + return { exchanges: SimulationLLMExchange.pending() } + case "network.log": + return { entries: SimulationNetwork.log() } + } + throw new Error(`Unknown simulation control method: ${request.method}`) +} + +function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> { + try { + return Bun.serve<{ unsubscribe?: () => void }>({ + hostname: "127.0.0.1", + port, + fetch(request, server) { + if (server.upgrade(request, { data: {} })) return undefined + return new Response("opencode simulation control websocket", { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.unsubscribe?.() + }, + async message(socket, message) { + let request: SimulationProtocol.JsonRpc.Request | undefined + try { + request = parseRequest(message) + const result = await handle(socket, request) + const response = SimulationProtocol.JsonRpc.success(request.id, result) + if (response) socket.send(JSON.stringify(response)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } + }, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + const unavailable = message.includes("eaddrinuse") || message.includes("in use") + if (!unavailable || attempts <= 1 || port >= 65535) throw error + return serve(port + 1, attempts - 1) + } +} + +export function start() { + const server = serve() + const url = `ws://${server.hostname}:${server.port}` + process.stderr.write(`opencode simulation backend control websocket: ${url}\n`) + return { + url, + stop: () => { + server.stop(true) + }, + } +} + +export * as SimulationControl from "./control" diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts new file mode 100644 index 0000000000..01a7b00329 --- /dev/null +++ b/packages/simulation/src/backend/filesystem.ts @@ -0,0 +1,390 @@ +import { Effect, FileSystem, Layer, Option, Stream } from "effect" +import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" +import nodeFs from "fs" +import path from "path" + +/** + * In-memory simulated `FileSystem.FileSystem`. + * + * Replaces the `NodeFileSystem` platform node when the server runs in + * simulation mode. Backed by a flat map of absolute paths to entries and + * rooted at a single directory (the simulation anchor): paths that resolve + * outside the root fail with `PermissionDenied` so host filesystem escapes + * are loud. Only the operations the app actually uses are implemented; + * everything else dies with a clear defect. + * + * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten + * for the V2 platform node shape without the `just-bash` dependency. + */ + +export interface Options { + readonly root: string + readonly files?: Record +} + +interface FileEntry { + readonly type: "File" + content: Uint8Array + mode: number + mtime: Date +} + +interface DirectoryEntry { + readonly type: "Directory" + mode: number + mtime: Date +} + +type Entry = FileEntry | DirectoryEntry + +export function make(options: Options): FileSystem.FileSystem { + const root = path.resolve(options.root) + const store = new Map() + const temp = { value: 0 } + const encoder = new TextEncoder() + store.set(root, makeDirectoryEntry()) + + const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) + + const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) + + const fail = ( + tag: SystemErrorTag, + method: string, + file: string, + description?: string, + ): Effect.Effect => + Effect.fail( + systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), + ) + + const locate = (method: string, file: string): Effect.Effect => { + const resolved = path.resolve(root, file) + if (within(resolved)) return Effect.succeed(resolved) + return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") + } + + const requireEntry = (method: string, file: string): Effect.Effect => + locate(method, file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return fail("NotFound", method, file) + return Effect.succeed([resolved, entry] as const) + }), + ) + + const requireParentDirectory = ( + method: string, + resolved: string, + file: string, + ): Effect.Effect => { + const parent = store.get(path.dirname(resolved)) + if (parent?.type === "Directory") return Effect.void + return fail("NotFound", method, file, "parent directory does not exist") + } + + // Creates every missing directory between root and resolved (inclusive). + const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => + Effect.suspend(() => { + const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) + const conflict = segments.reduce>((current, segment) => { + if (typeof current !== "string") return current + const next = path.join(current, segment) + const entry = store.get(next) + if (entry && entry.type !== "Directory") + return fail("AlreadyExists", method, file, "path component is not a directory") + if (!entry) store.set(next, makeDirectoryEntry()) + return next + }, root) + return typeof conflict === "string" ? Effect.void : conflict + }) + + // Seed initial files, creating parents as needed. Entries outside the root are ignored. + for (const [file, content] of Object.entries(options.files ?? {})) { + const resolved = path.resolve(root, file) + if (!within(resolved)) continue + Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) + store.set(resolved, { + type: "File", + content: typeof content === "string" ? encoder.encode(content) : content.slice(), + mode: 0o644, + mtime: new Date(), + }) + } + + // Probe operations report NotFound outside the root instead of + // PermissionDenied: walk-up loops (project discovery, findUp, globUp) + // legitimately probe ancestor directories of the anchor and must observe + // "nothing there". Content access and mutation outside the root stay loud. + const probe = (method: string, file: string): Effect.Effect => + Effect.suspend(() => { + const resolved = path.resolve(root, file) + const entry = within(resolved) ? store.get(resolved) : undefined + if (!entry) return fail("NotFound", method, file) + return Effect.succeed(entry) + }) + + const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) + + const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) + + const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => + requireEntry("chmod", file).pipe( + Effect.map(([, entry]) => { + entry.mode = mode + }), + ) + + const realPath: FileSystem.FileSystem["realPath"] = (file) => + requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) + + const readFile: FileSystem.FileSystem["readFile"] = (file) => + requireEntry("readFile", file).pipe( + Effect.flatMap(([, entry]) => { + if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") + return Effect.succeed(entry.content.slice()) + }), + ) + + const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => + locate("writeFile", file).pipe( + Effect.flatMap((resolved) => { + const existing = store.get(resolved) + if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") + return requireParentDirectory("writeFile", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { + type: "File", + content: data.slice(), + mode: writeOptions?.mode ?? existing?.mode ?? 0o644, + mtime: new Date(), + }) + }), + ) + }), + ) + + const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => + locate("makeDirectory", file).pipe( + Effect.flatMap((resolved) => { + if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) + if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) + return requireParentDirectory("makeDirectory", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) + }), + ) + }), + ) + + const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => + requireEntry("readDirectory", file).pipe( + Effect.flatMap(([resolved, entry]) => { + if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") + const children = childrenOf(resolved) + const names = readOptions?.recursive + ? children.map((key) => path.relative(resolved, key)) + : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) + return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) + }), + ) + + const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => + locate("remove", file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) + const children = childrenOf(resolved) + if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) + return fail("Unknown", "remove", file, "directory is not empty") + for (const key of children) store.delete(key) + store.delete(resolved) + // The root itself must always exist. + if (resolved === root) store.set(root, makeDirectoryEntry()) + return Effect.void + }), + ) + + const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => + Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "rename", oldPath) + return requireParentDirectory("rename", to, newPath).pipe( + Effect.map(() => { + const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) + for (const [key] of moved) store.delete(key) + for (const key of [to, ...childrenOf(to)]) store.delete(key) + for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) + }), + ) + }), + ) + + const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => + Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "copy", fromPath) + return requireParentDirectory("copy", to, toPath).pipe( + Effect.map(() => { + for (const key of [from, ...childrenOf(from)]) { + const source = store.get(key)! + const target = key === from ? to : to + key.slice(from.length) + store.set( + target, + source.type === "File" + ? { ...source, content: source.content.slice(), mtime: new Date() } + : { ...source, mtime: new Date() }, + ) + } + }), + ) + }), + ) + + const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => + readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) + + const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => + Effect.suspend(() => { + const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") + const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) + return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) + }) + + const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => + Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => + remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ) + + // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. + const open: FileSystem.FileSystem["open"] = (file) => + requireEntry("open", file).pipe( + Effect.map(([resolved]) => { + const position = { value: 0 } + const contentOf = () => { + const current = store.get(resolved) + return current?.type === "File" ? current.content : new Uint8Array() + } + return { + [FileSystem.FileTypeId]: FileSystem.FileTypeId, + fd: FileSystem.FileDescriptor(0), + stat: Effect.suspend(() => stat(resolved)), + seek: (offset, from) => + Effect.sync(() => { + position.value = from === "start" ? Number(offset) : position.value + Number(offset) + }), + sync: Effect.void, + read: (buffer) => + Effect.sync(() => { + const chunk = contentOf().subarray(position.value, position.value + buffer.length) + buffer.set(chunk) + position.value += chunk.length + return FileSystem.Size(chunk.length) + }), + readAlloc: (size) => + Effect.sync(() => { + const chunk = contentOf().slice(position.value, position.value + Number(size)) + position.value += chunk.length + return chunk.length === 0 ? Option.none() : Option.some(chunk) + }), + truncate: () => unimplemented("File.truncate"), + write: () => unimplemented("File.write"), + writeAll: () => unimplemented("File.writeAll"), + } satisfies FileSystem.File + }), + ) + + return FileSystem.make({ + access, + chmod, + chown: () => unimplemented("chown"), + copy, + copyFile, + link: () => unimplemented("link"), + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile: () => unimplemented("makeTempFile"), + makeTempFileScoped: () => unimplemented("makeTempFileScoped"), + open, + readDirectory, + readFile, + readLink: () => unimplemented("readLink"), + realPath, + remove, + rename, + stat, + symlink: () => unimplemented("symlink"), + truncate: () => unimplemented("truncate"), + utimes: () => unimplemented("utimes"), + watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), + writeFile, + }) +} + +/** + * Lazily constructed layer so the root defaults to `process.cwd()` at + * layer-build time (the simulation anchor directory), not at import time. + * + * When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its + * `project/` contents are read from the host once at build time and seeded + * into the in-memory tree, joined onto the anchor root. + */ +export const layer = (options?: Partial) => + Layer.sync(FileSystem.FileSystem)(() => + make({ + root: options?.root ?? process.cwd(), + files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, + }), + ) + +function loadSnapshotFiles(stateDirectory: string | undefined) { + if (!stateDirectory) return {} + const project = path.join(stateDirectory, "project") + if (!nodeFs.existsSync(project)) return {} + const files: Record = {} + const walk = (dir: string) => { + for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) walk(file) + if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file)) + } + } + walk(project) + return files +} + +function makeDirectoryEntry(): Entry { + return { type: "Directory", mode: 0o755, mtime: new Date() } +} + +function withSep(dir: string) { + return dir.endsWith(path.sep) ? dir : dir + path.sep +} + +function toInfo(entry: Entry): FileSystem.File.Info { + return { + type: entry.type, + mtime: Option.some(entry.mtime), + atime: Option.some(entry.mtime), + birthtime: Option.some(entry.mtime), + dev: 0, + ino: Option.none(), + mode: entry.mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), + blksize: Option.none(), + blocks: Option.none(), + } +} + +function unimplemented(method: string) { + return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) +} + +export * as SimulationFileSystem from "./filesystem" diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts new file mode 100644 index 0000000000..e24d0c9c4b --- /dev/null +++ b/packages/simulation/src/backend/fs-util.ts @@ -0,0 +1,89 @@ +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Glob } from "@opencode-ai/core/util/glob" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import path from "path" + +/** + * Simulation replacement for `FSUtil`. + * + * The real `FSUtil` layer builds most helpers on the injected + * `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp` + * reach for node `fs/promises` and the `glob` package directly, and `resolve` + * canonicalizes through the host filesystem. This wraps the real layer and + * reroutes those through the injected `FileSystem`/lexical path resolution so + * every read observes the in-memory tree. + */ + +const layer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const base = yield* FSUtil.Service + const fs = yield* FileSystem.FileSystem + + const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { + return input + }) + + const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { + const names = yield* fs.readDirectory(dirPath) + return yield* Effect.forEach(names, (name) => + fs.stat(path.join(dirPath, name)).pipe( + Effect.map( + (info): FSUtil.DirEntry => ({ + name, + type: + info.type === "Directory" + ? "directory" + : info.type === "File" + ? "file" + : info.type === "SymbolicLink" + ? "symlink" + : "other", + }), + ), + Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), + ), + ) + }) + + const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { + const cwd = path.resolve(options?.cwd ?? process.cwd()) + const entries = yield* fs + .readDirectory(cwd, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as string[])) + const matches = yield* Effect.forEach(entries, (entry) => + fs.stat(path.join(cwd, entry)).pipe( + Effect.map((info) => ({ entry, type: info.type })), + Effect.orElseSucceed(() => undefined), + ), + ) + return matches + .filter((item) => item !== undefined) + .filter((item) => options?.include === "all" || item.type === "File") + .filter((item) => Glob.match(pattern, item.entry)) + .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) + .sort((a, b) => a.localeCompare(b)) + }) + + const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = path.resolve(start) + while (true) { + result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) + if (stop === current) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return FSUtil.Service.of({ ...base, readDirectoryEntries, resolve, glob, globUp }) + }), +).pipe(Layer.provide(FSUtil.layer)) + +export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) + +export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts new file mode 100644 index 0000000000..dd735d40dd --- /dev/null +++ b/packages/simulation/src/backend/index.ts @@ -0,0 +1,41 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { SimulationControl } from "./control" +import { SimulationFileSystem } from "./filesystem" +import { SimulationFSUtil } from "./fs-util" +import { SimulationNetwork } from "./network" +import { SimulationOpenAI } from "./openai" + +/** + * Layer replacements applied when the server is built in simulation mode. + * + * The server merges these into the app node build when `OPENCODE_SIMULATION` + * is enabled, via a dynamic import so this module is never loaded eagerly. + * + * - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real, + * empty anchor directory the runner created and chdir'd into). Everything + * under the root lives in memory; paths outside it fail loudly. + * - Network: all outbound HTTP resolves against the simulated route table; + * unknown destinations are denied. The driver-answered OpenAI endpoint is + * registered here as the first route. + * + * Loading this module also starts the backend simulation control WebSocket, + * which drivers connect to directly for LLM exchange control and network + * inspection (standalone topology; also the headless-simulation interface). + */ + +SimulationNetwork.register(SimulationOpenAI.route) +// ModelsDev dies when its catalog fetch fails, so simulation answers it with +// an empty catalog; providers come from seeded config instead. +SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) + +SimulationControl.start() + +export const simulationReplacements: LayerNode.Replacements = [ + [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], + [FSUtil.node, SimulationFSUtil.node], + [httpClient, SimulationNetwork.layer], +] + +export * as Simulation from "./index" diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts new file mode 100644 index 0000000000..759f52866c --- /dev/null +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -0,0 +1,105 @@ +import { Effect, Queue } from "effect" + +/** + * Pending driver-answered LLM exchanges. + * + * When the simulated network receives a provider request it opens an + * exchange: the parsed request body plus a queue of response chunks. The + * simulation control WebSocket notifies the external driver, and the driver + * pushes chunks back until it finishes the exchange. The driver is the + * model; nothing is scripted or enqueued server-side. + * + * Process-global by design (plain module state, like the network route + * table): the simulated network and the control server must observe the same + * exchanges regardless of which layer instance touched them. + */ + +/** One response item the driver sends back. Compiled to provider wire chunks by the endpoint. */ +export type Item = + | { readonly type: "textDelta"; readonly text: string } + | { readonly type: "reasoningDelta"; readonly text: string } + | { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown } + | { readonly type: "raw"; readonly chunk: unknown } + +export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" + +export type Chunk = + | { readonly type: "item"; readonly item: Item } + | { readonly type: "finish"; readonly reason: FinishReason } + +export interface Exchange { + readonly id: string + readonly url: string + readonly body: unknown + readonly queue: Queue.Queue +} + +export interface OpenedExchange { + readonly id: string + readonly url: string + readonly body: unknown +} + +const state = { + counter: 0, + exchanges: new Map(), + listeners: new Set<(exchange: OpenedExchange) => void>(), +} + +export class ExchangeNotFoundError extends Error { + constructor(id: string) { + super(`Simulation LLM exchange not found or already finished: ${id}`) + } +} + +/** Opens an exchange and notifies listeners. Called by the simulated provider endpoint. */ +export const open = (input: { readonly url: string; readonly body: unknown }) => + Effect.gen(function* () { + const id = `ex_${++state.counter}` + const queue = yield* Queue.unbounded() + const exchange: Exchange = { id, url: input.url, body: input.body, queue } + state.exchanges.set(id, exchange) + for (const listener of state.listeners) listener({ id, url: input.url, body: input.body }) + return exchange + }) + +/** Closes an exchange without consuming remaining chunks (response interrupted or finished). */ +export const close = (id: string) => + Effect.suspend(() => { + const exchange = state.exchanges.get(id) + state.exchanges.delete(id) + if (!exchange) return Effect.void + return Queue.shutdown(exchange.queue).pipe(Effect.asVoid) + }) + +/** Appends response chunks to an open exchange. Driver-facing. */ +export const push = (id: string, chunks: readonly Chunk[]) => + Effect.gen(function* () { + const exchange = state.exchanges.get(id) + if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id)) + yield* Queue.offerAll(exchange.queue, chunks) + }) + +/** + * Registers a listener for newly opened exchanges and immediately replays + * currently-pending ones, so a late-attaching driver observes requests that + * arrived before it connected. Returns an unsubscribe function. + */ +export function subscribe(listener: (exchange: OpenedExchange) => void) { + state.listeners.add(listener) + for (const exchange of pending()) listener(exchange) + return () => { + state.listeners.delete(listener) + } +} + +/** Snapshot of currently open exchanges, for control-surface inspection. */ +export function pending(): OpenedExchange[] { + return [...state.exchanges.values()].map((exchange) => ({ + id: exchange.id, + url: exchange.url, + body: exchange.body, + })) +} + +export * as SimulationLLMExchange from "./llm-exchange" diff --git a/packages/simulation/src/backend/network.ts b/packages/simulation/src/backend/network.ts new file mode 100644 index 0000000000..7b002fcb4e --- /dev/null +++ b/packages/simulation/src/backend/network.ts @@ -0,0 +1,94 @@ +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" +import type { HttpClientRequest } from "effect/unstable/http" + +/** + * Simulated network. + * + * Replaces the `HttpClient.HttpClient` platform node in simulation mode. All + * outbound HTTP resolves against an in-memory route table; unknown + * destinations fail loudly with a transport error so no simulation run can + * silently reach the real network. The scripted LLM is one registered route, + * not a separate mechanism. + * + * The route table is process-global module state so the control surface and + * the client layer observe the same registrations. + */ + +export interface Route { + /** Return a response effect to claim the request, undefined to pass. */ + readonly match: ( + request: HttpClientRequest.HttpClientRequest, + url: URL, + ) => Effect.Effect | undefined +} + +interface LogEntry { + readonly time: number + readonly method: string + readonly url: string + readonly matched: boolean +} + +const state = { + routes: [] as Route[], + log: [] as LogEntry[], +} + +const LOG_LIMIT = 1000 + +export function register(route: Route) { + state.routes.push(route) + return () => { + const index = state.routes.indexOf(route) + if (index >= 0) state.routes.splice(index, 1) + } +} + +/** Static JSON route: exact method + origin/path match answered with a fixed body. */ +export function json(method: string, url: string, body: unknown): Route { + return { + match: (request, requestUrl) => { + if (request.method !== method) return undefined + if (requestUrl.origin + requestUrl.pathname !== url) return undefined + return Effect.sync(() => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }), + ), + ) + }, + } +} + +export function log(): readonly LogEntry[] { + return state.log +} + +function record(entry: LogEntry) { + state.log.push(entry) + if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT) +} + +export const layer = Layer.sync(HttpClient.HttpClient)(() => + HttpClient.make((request, url) => + Effect.suspend(() => { + const matched = state.routes + .map((route) => route.match(request, url)) + .find((response) => response !== undefined) + record({ time: Date.now(), method: request.method, url: url.toString(), matched: matched !== undefined }) + if (matched) return matched + return Effect.fail( + new HttpClientError({ + reason: new TransportError({ + request, + description: `Simulation denied unregistered network destination: ${request.method} ${url}`, + }), + }), + ) + }), + ), +) + +export * as SimulationNetwork from "./network" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts new file mode 100644 index 0000000000..c13e7772ef --- /dev/null +++ b/packages/simulation/src/backend/openai.ts @@ -0,0 +1,89 @@ +import { Effect, Schema, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * Driver-answered OpenAI endpoint for the simulated network. + * + * Claims `POST {DEFAULT_BASE_URL}{PATH}` (the real openai-chat route + * endpoint), opens an LLM exchange, and streams the driver's chunks back as + * an OpenAI Chat SSE response terminated by `[DONE]`. Everything downstream + * of the response bytes is the real pipeline: SSE framing, the OpenAIChat + * event schema, the protocol state machine, and Lifecycle grammar. + */ + +const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) + +const encoder = new TextEncoder() + +// The simulated model id is echoed back only in non-schema fields; the +// protocol event schema ignores unknown fields, so id/object/model are +// decorative wire realism. +function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { + if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] } + if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] } + if (item.type === "toolCall") + return { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + ], + }, + }, + ], + } + return item.chunk +} + +const finishReasonWire: Record = { + stop: "stop", + "tool-calls": "tool_calls", + length: "length", + "content-filter": "content_filter", +} + +function frame(payload: unknown): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) +} + +function sseBody(exchange: SimulationLLMExchange.Exchange): Stream.Stream { + const chunks = Stream.fromQueue(exchange.queue).pipe( + Stream.takeUntil((chunk) => chunk.type === "finish"), + Stream.map((chunk) => { + if (chunk.type === "finish") + return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[chunk.reason] }] })) + if (chunk.item.type === "raw") return frame(chunk.item.chunk) + return frame(encodeChunk(chunkOf(chunk.item))) + }), + ) + return chunks.pipe( + Stream.concat(Stream.make(encoder.encode("data: [DONE]\n\n"))), + // Close the exchange when the response body ends or is interrupted, so + // late driver pushes fail with ExchangeNotFoundError instead of leaking. + Stream.ensuring(SimulationLLMExchange.close(exchange.id)), + ) +} + +export const route: SimulationNetwork.Route = { + match: (request, url) => { + if (request.method !== "POST") return undefined + if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined + return Effect.gen(function* () { + const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {} + const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body }) + return HttpClientResponse.fromWeb( + request, + new Response(Stream.toReadableStream(sseBody(exchange)), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ) + }) + }, +} + +export * as SimulationOpenAI from "./openai" diff --git a/packages/tui/src/simulation/actions.ts b/packages/simulation/src/frontend/actions.ts similarity index 84% rename from packages/tui/src/simulation/actions.ts rename to packages/simulation/src/frontend/actions.ts index 68725ec252..d03e4a5f75 100644 --- a/packages/tui/src/simulation/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,36 +1,11 @@ import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" +import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" import { SimulationTrace } from "./trace" -export interface KeyModifiers { - readonly ctrl?: boolean - readonly shift?: boolean - readonly meta?: boolean - readonly super?: boolean - readonly hyper?: boolean -} - -export type Action = - | { readonly type: "typeText"; readonly text: string } - | { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers } - | { readonly type: "pressEnter" } - | { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" } - | { readonly type: "focus"; readonly target: number } - | { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number } - -export interface Element { - readonly id: string - readonly num: number - readonly x: number - readonly y: number - readonly width: number - readonly height: number - readonly focusable: boolean - readonly focused: boolean - readonly clickable: boolean - readonly editor: boolean -} +export type Action = SimulationProtocol.Frontend.Action +export type Element = SimulationProtocol.Frontend.Element export interface Harness { readonly renderer: CliRenderer diff --git a/packages/tui/src/simulation/renderer.ts b/packages/simulation/src/frontend/renderer.ts similarity index 100% rename from packages/tui/src/simulation/renderer.ts rename to packages/simulation/src/frontend/renderer.ts diff --git a/packages/tui/src/simulation/server.ts b/packages/simulation/src/frontend/server.ts similarity index 52% rename from packages/tui/src/simulation/server.ts rename to packages/simulation/src/frontend/server.ts index 65d814d2f5..c6a6c8784c 100644 --- a/packages/tui/src/simulation/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,27 +1,10 @@ -import { SimulationActions, type Action, type Harness } from "./actions" +import { SimulationProtocol } from "../protocol" +import { SimulationActions, type Harness } from "./actions" import { SimulationTrace } from "./trace" const DefaultPort = 40900 const MaxPortAttempts = 100 -type JsonRpcRequest = { - readonly jsonrpc: "2.0" - readonly id?: string | number | null - readonly method: string - readonly params?: unknown -} - -type JsonRpcResponse = { - readonly jsonrpc: "2.0" - readonly id: string | number | null - readonly result?: unknown - readonly error?: { - readonly code: number - readonly message: string - readonly data?: unknown - } -} - export interface Server { readonly url: string readonly stop: () => void @@ -36,63 +19,15 @@ function isPortUnavailable(error: unknown) { return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") } -function parseRequest(input: string | Buffer): JsonRpcRequest { - const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown - if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request") - if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version") - if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method") - return value as JsonRpcRequest -} - -function isAction(input: unknown): input is Action { - if (typeof input !== "object" || input === null || !("type" in input)) return false - switch (input.type) { - case "typeText": - return "text" in input && typeof input.text === "string" - case "pressKey": - return "key" in input && typeof input.key === "string" - case "pressEnter": - return true - case "pressArrow": - return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction)) - case "focus": - return "target" in input && typeof input.target === "number" - case "click": - return ( - "target" in input && - typeof input.target === "number" && - "x" in input && - typeof input.x === "number" && - "y" in input && - typeof input.y === "number" - ) - } - return false -} - function actionParam(params: unknown) { - if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action") - if (!isAction(params.action)) throw new Error("Invalid action") - return params.action + return SimulationProtocol.Frontend.decodeActionParams(params).action } -function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined { - if (id === undefined) return undefined - return { jsonrpc: "2.0", id, result } +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse { - return { - jsonrpc: "2.0", - id: id ?? null, - error: { - code: -32000, - message: error instanceof Error ? error.message : String(error), - }, - } -} - -async function handle(harness: Harness, request: JsonRpcRequest) { +async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { switch (request.method) { case "ui.state": { const result = SimulationActions.state(harness) @@ -139,14 +74,14 @@ function serve( SimulationTrace.add("control.disconnect") }, async message(socket, message) { - let request: JsonRpcRequest | undefined + let request: SimulationProtocol.JsonRpc.Request | undefined try { request = parseRequest(message) const result = await handle(harness, request) - const next = response(request.id, result) + const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { - socket.send(JSON.stringify(errorResponse(request?.id, error))) + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) } }, }, diff --git a/packages/tui/src/simulation/simulation.ts b/packages/simulation/src/frontend/simulation.ts similarity index 100% rename from packages/tui/src/simulation/simulation.ts rename to packages/simulation/src/frontend/simulation.ts diff --git a/packages/tui/src/simulation/trace.ts b/packages/simulation/src/frontend/trace.ts similarity index 100% rename from packages/tui/src/simulation/trace.ts rename to packages/simulation/src/frontend/trace.ts diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts new file mode 100644 index 0000000000..6ff63c9bb3 --- /dev/null +++ b/packages/simulation/src/protocol/index.ts @@ -0,0 +1,145 @@ +import { Effect, Schema } from "effect" + +const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) +type Json = Schema.Schema.Type + +export namespace JsonRpc { + export const Request = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.optional(JsonRpcID), + method: Schema.String, + params: Schema.optional(Schema.Json), + }) + export interface Request extends Schema.Schema.Type {} + + export const ErrorObject = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optional(Schema.Json), + }) + + export const Response = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: JsonRpcID, + result: Schema.optional(Schema.Json), + error: Schema.optional(ErrorObject), + }) + export interface Response extends Schema.Schema.Type {} + + export const decodeRequest = Schema.decodeUnknownSync(Request) + + export function success(id: Request["id"], result: unknown): Response | undefined { + if (id === undefined) return undefined + return { jsonrpc: "2.0", id, result: result as Json } + } + + export function failure(id: Request["id"], error: unknown): Response { + return { + jsonrpc: "2.0", + id: id ?? null, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + } + } +} + +export namespace Frontend { + export const KeyModifiers = Schema.Struct({ + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), + }) + export interface KeyModifiers extends Schema.Schema.Type {} + + export const Action = Schema.Union([ + Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("pressEnter") }), + Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + ]) + export type Action = Schema.Schema.Type + + export const Element = Schema.Struct({ + id: Schema.String, + num: Schema.Number, + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + focusable: Schema.Boolean, + focused: Schema.Boolean, + clickable: Schema.Boolean, + editor: Schema.Boolean, + }) + export interface Element extends Schema.Schema.Type {} + + export const State = Schema.Struct({ + screen: Schema.String, + focused: Schema.Struct({ + renderable: Schema.optional(Schema.Number), + editor: Schema.Boolean, + }), + elements: Schema.Array(Element), + actions: Schema.Array(Action), + }) + export interface State extends Schema.Schema.Type {} + + export const ActionParams = Schema.Struct({ action: Action }) + export interface ActionParams extends Schema.Schema.Type {} + export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + + export const TraceRecord = Schema.Struct({ + id: Schema.Number, + time: Schema.String, + type: Schema.String, + data: Schema.optional(Schema.Json), + }) + export interface TraceRecord extends Schema.Schema.Type {} + + export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) + export interface TraceList extends Schema.Schema.Type {} +} + +export namespace Backend { + export const Item = Schema.Union([ + Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }), + Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), + ]) + export type Item = Schema.Schema.Type + + export const FinishReason = Schema.Literals(["stop", "tool-calls", "length", "content-filter"]) + export type FinishReason = Schema.Schema.Type + + export const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(Item) }) + export interface ChunkParams extends Schema.Schema.Type {} + + export const FinishParams = Schema.Struct({ + id: Schema.String, + reason: FinishReason.pipe(Schema.withDecodingDefault(Effect.succeed("stop" as const))), + }) + export interface FinishParams extends Schema.Schema.Type {} + + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) + export interface OpenedExchange extends Schema.Schema.Type {} + + export const NetworkLogEntry = Schema.Struct({ + time: Schema.Number, + method: Schema.String, + url: Schema.String, + matched: Schema.Boolean, + }) + export interface NetworkLogEntry extends Schema.Schema.Type {} + + export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) + export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) +} + +export * as SimulationProtocol from "./index" diff --git a/packages/simulation/tsconfig.json b/packages/simulation/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/simulation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 8bf349c2da..4dade71898 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -51,6 +51,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ccde2fa5e6..d5cf93c2c7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -139,6 +139,7 @@ export type TuiInput = { client: OpencodeClient api: OpenCodeClient discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + reload?: () => Promise args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -199,8 +200,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig - if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") { - const { Simulation } = await import("./simulation/simulation") + if (!!process.env.OPENCODE_SIMULATION) { + const { Simulation } = await import("@opencode-ai/simulation/frontend") return Simulation.createSimulation(options) } @@ -301,7 +302,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -800,6 +801,26 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + ...(sdk.reload + ? [ + { + name: "server.reload", + title: "Reload server", + slashName: "reload", + run: async () => { + dialog.clear() + toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) + // reload resolves once the replacement service is healthy; the + // event stream reattaches through the reconnect loop. + await sdk + .reload!() + .then(() => toast.show({ variant: "success", message: "Server reloaded" })) + .catch(toast.error) + }, + category: "System", + }, + ] + : []), { name: "theme.switch", title: "Switch theme", diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7b0724e5dc..9fad2eb21c 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1101,13 +1101,8 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { move.startSubmit() - void sdk.client.session.shell({ + void sdk.client.v2.session.shell({ sessionID, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - modelID: selectedModel.modelID, - }, command: inputText, }) setStore("mode", "normal") diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b0a3ec122c..55d1503794 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -30,6 +30,8 @@ export type DataSessionStatus = "idle" | "running" export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } +const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -185,17 +187,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const info = store.session.info[sessionID] if (!info) return const rootID = resolveRoot(sessionID) - setStore("session", "family", produce((draft) => { - if (sessionID !== rootID && draft[sessionID]) { - const members = draft[rootID] ??= [] - for (const id of draft[sessionID]) { - if (!members.includes(id)) members.push(id) + setStore( + "session", + "family", + produce((draft) => { + if (sessionID !== rootID && draft[sessionID]) { + const members = (draft[rootID] ??= []) + for (const id of draft[sessionID]) { + if (!members.includes(id)) members.push(id) + } + delete draft[sessionID] } - delete draft[sessionID] - } - const family = draft[rootID] ??= [] - if (!family.includes(sessionID)) family.push(sessionID) - })) + const family = (draft[rootID] ??= []) + if (!family.includes(sessionID)) family.push(sessionID) + }), + ) } function handleEvent(event: V2Event) { @@ -218,124 +224,113 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "skill.updated": void result.location.skill.refresh(event.location) break - case "session.next.agent.switched": + case "agent.selected": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "agent", event.data.agent) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "agent-switched", agent: event.data.agent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.model.switched": + case "model.selected": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "model", event.data.model) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "model-switched", model: event.data.model, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.renamed": + case "renamed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break - case "session.next.prompted": { + case "prompt.promoted": { setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { - const position = index.get(event.data.messageID) + const position = index.get(event.data.inputID) const existing = position === undefined ? undefined : draft[position] if (existing?.type === "user") { - existing.text = event.data.prompt.text - existing.files = event.data.prompt.files - existing.agents = event.data.prompt.agents - existing.time.created = event.data.timestamp + existing.time.created = event.created if (existing.metadata?.queued === true) { delete existing.metadata.queued if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined } return } - message.append(draft, index, { - id: event.data.messageID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timestamp }, - }) }) break } - case "session.next.prompt.admitted": + case "prompt.admitted": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: event.data.inputID, type: "user", text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, metadata: { queued: true }, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.context.updated": + case "session.context.updated": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "system", text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.synthetic": + case "synthetic": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "synthetic", sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.shell.started": + case "shell.started": setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "shell", callID: event.data.callID, command: event.data.command, output: "", - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.shell.ended": + case "shell.ended": setStore("session", "status", event.data.sessionID, "idle") message.update(event.data.sessionID, (draft, index) => { const match = message.activeShell(draft, event.data.callID) if (!match) return match.output = event.data.output - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.step.started": + case "step.started": setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { if (index.has(event.data.assistantMessageID)) return const currentAssistant = message.activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.data.timestamp + if (currentAssistant) currentAssistant.time.completed = event.created message.append(draft, index, { id: event.data.assistantMessageID, type: "assistant", @@ -343,16 +338,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ model: event.data.model, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.step.ended": + case "step.ended": setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.data.timestamp + currentAssistant.time.completed = event.created currentAssistant.finish = event.data.finish currentAssistant.cost = event.data.cost currentAssistant.tokens = event.data.tokens @@ -360,16 +355,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break - case "session.next.step.failed": + case "step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.data.timestamp + currentAssistant.time.completed = event.created currentAssistant.finish = "error" currentAssistant.error = event.data.error }) break - case "session.next.text.started": + case "text.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", @@ -378,7 +373,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "session.next.text.delta": + case "text.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -387,7 +382,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "session.next.text.ended": + case "text.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -396,18 +391,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text = event.data.text }) break - case "session.next.tool.input.started": + case "tool.input.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "tool", id: event.data.callID, name: event.data.name, - time: { created: event.data.timestamp }, + time: { created: event.created }, state: { status: "pending", input: "" }, }) }) break - case "session.next.tool.input.delta": + case "tool.input.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -416,7 +411,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input += event.data.delta }) break - case "session.next.tool.input.ended": + case "tool.input.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -425,19 +420,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input = event.data.text }) break - case "session.next.tool.called": + case "tool.called": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) if (!match) return - match.time.ran = event.data.timestamp + match.time.ran = event.created match.provider = event.data.provider match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break - case "session.next.tool.progress": + case "tool.progress": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -448,7 +443,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.state.content = [...event.data.content] }) break - case "session.next.tool.success": + case "tool.success": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -467,10 +462,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.tool.failed": + case "tool.failed": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -490,21 +485,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.reasoning.started": + case "reasoning.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.reasoning.delta": + case "reasoning.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -513,7 +508,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "session.next.reasoning.ended": + case "reasoning.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -521,38 +516,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) if (match) { match.text = event.data.text - match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } + match.time = { created: match.time?.created ?? event.created, completed: event.created } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) break - case "session.next.retried": - case "session.next.compaction.started": + case "retried": + case "compaction.started": setStore("session", "status", event.data.sessionID, "running") break - case "session.next.execution.settled": + case "execution.settled": setStore("session", "status", event.data.sessionID, "idle") break - case "session.next.revert.staged": + case "revert.staged": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", event.data.revert) break - case "session.next.revert.cleared": - case "session.next.revert.committed": + case "revert.cleared": + case "revert.committed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", undefined) break - case "session.next.compaction.delta": + case "compaction.delta": break - case "session.next.compaction.ended": + case "compaction.ended": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "compaction", reason: event.data.reason, summary: event.data.text, recent: event.data.recent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break @@ -705,8 +700,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return liveByID.get(message.id) ?? message }), ...live.filter((message) => !loadedIDs.has(message.id)), - ] - .toSorted((a, b) => a.time.created - b.time.created) + ].toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) }, diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 7686ed6e94..0ad516ceac 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -16,6 +16,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ client: OpencodeClient api: OpenCodeClient discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + // Stops and starts the managed service; present only in service mode. + reload?: () => Promise }) => { const abort = new AbortController() let client = props.client @@ -138,6 +140,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.connectedOnce }, }, + reload: props.reload, } }, }) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 633904be95..77ea9cf160 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -74,20 +74,17 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - api.event.on("session.next.prompted", (event) => started(event.data.sessionID)) - api.event.on("session.next.shell.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.step.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.retried", (event) => started(event.data.sessionID)) - api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.execution.settled", (event) => { - if (event.data.outcome === "failure" && active.has(event.data.sessionID) && !errored.has(event.data.sessionID)) { - errored.add(event.data.sessionID) - notify(api, event.data.sessionID, "Session error", "error") - } - if (event.data.outcome === "interrupted") errored.add(event.data.sessionID) + api.event.on("prompt.promoted", (event) => started(event.data.sessionID)) + api.event.on("shell.started", (event) => started(event.data.sessionID)) + api.event.on("step.started", (event) => started(event.data.sessionID)) + api.event.on("retried", (event) => started(event.data.sessionID)) + api.event.on("compaction.started", (event) => started(event.data.sessionID)) + api.event.on("shell.ended", (event) => ended(event.data.sessionID)) + api.event.on("step.ended", (event) => { + if (event.data.finish === "tool-calls") return ended(event.data.sessionID) }) - api.event.on("session.next.step.failed", (event) => { + api.event.on("step.failed", (event) => { const sessionID = event.data.sessionID if (!active.has(sessionID)) return errored.add(sessionID) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ffeae192b3..f4972802df 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1081,9 +1081,7 @@ function SessionMessageView(props: { message: SessionMessage }) { - - {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} - + } /> @@ -1359,6 +1357,29 @@ function RevertMessage(props: { ) } +function ShellMessage(props: { message: Extract }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + + return ( + + $ {props.message.command} + + {output()} + + + ) +} + function UserMessage(props: { message: SessionMessageUser }) { const ctx = use() const data = useData() diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index f1c4714276..23b43c3b17 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -126,45 +126,49 @@ export function createSessionRows(sessionID: Accessor) { return index === -1 ? rows.length : index } - const message = (event: { data: { sessionID: string; messageID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID) + const message = (event: { id: string; data: { sessionID: string } }) => { + if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) + } + const input = (event: { data: { sessionID: string; inputID: string } }) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) } const subscriptions = [ - data.on("session.next.prompt.admitted", message), - data.on("session.next.prompted", message), - data.on("session.next.context.updated", message), - data.on("session.next.synthetic", (event) => { - if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID) + data.on("prompt.admitted", input), + data.on("prompt.promoted", input), + data.on("session.context.updated", message), + data.on("synthetic", (event) => { + if (event.data.sessionID === sessionID() && event.data.description?.trim()) + appendMessage(event.id.replace(/^evt_/, "msg_")) }), - data.on("session.next.shell.started", message), - data.on("session.next.agent.switched", message), - data.on("session.next.model.switched", message), - data.on("session.next.compaction.ended", message), - data.on("session.next.text.delta", (event) => { + data.on("shell.started", message), + data.on("agent.selected", message), + data.on("model.selected", message), + data.on("compaction.ended", message), + data.on("text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) }), - data.on("session.next.text.ended", (event) => { + data.on("text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) }), - data.on("session.next.reasoning.delta", (event) => { + data.on("reasoning.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) }), - data.on("session.next.reasoning.ended", (event) => { + data.on("reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) }), - data.on("session.next.tool.input.started", (event) => { + data.on("tool.input.started", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) }), - data.on("session.next.step.ended", (event) => { + data.on("step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) }), - data.on("session.next.step.failed", (event) => { + data.on("step.failed", (event) => { if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) }), ] @@ -229,8 +233,6 @@ function hasPart(rows: SessionRow[], ref: PartRef) { return rows.some((row) => { if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID if (row.type !== "group") return false - return [...row.refs, ...row.pending].some( - (item) => item.messageID === ref.messageID && item.partID === ref.partID, - ) + return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID) }) } diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 5f11067b39..1e0902644f 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -33,10 +33,7 @@ async function setup() { }, }, event: { - on: ( - type: Type, - handler: (event: Extract) => void, - ) => { + on: (type: Type, handler: (event: Extract) => void) => { const list = handlers.get(type) ?? [] const wrapped = handler as (event: V2Event) => void list.push(wrapped) @@ -86,28 +83,37 @@ function permission(id: string, sessionID = "session"): PermissionV2Request { } } +function durable(sessionID: string) { + return { aggregateID: sessionID, seq: 0, version: 1 } +} + function stepStarted(id: string, sessionID = "session"): V2Event { return { id, - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID: `msg_${id}`, - timestamp: 0, agent: "build", model: { id: "model", providerID: "provider" }, }, } } -function executionSettled(id: string, sessionID = "session"): V2Event { +function executionSettled(id: string, sessionID = "session", finish = "stop"): V2Event { return { id, - type: "session.next.execution.settled", + created: 0, + type: "step.ended", + durable: durable(sessionID), data: { sessionID, - timestamp: 0, - outcome: "success", + assistantMessageID: `msg_${id}`, + finish, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }, } } @@ -115,11 +121,12 @@ function executionSettled(id: string, sessionID = "session"): V2Event { function stepFailed(id: string, sessionID = "session"): V2Event { return { id, - type: "session.next.step.failed", + created: 0, + type: "step.failed", + durable: durable(sessionID), data: { sessionID, assistantMessageID: `msg_${id}`, - timestamp: 0, error: { type: "unknown", message: "boom" }, }, } @@ -143,8 +150,8 @@ describe("internal notifications TUI plugin", () => { test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1") } }) - harness.emit({ id: "event-2", type: "permission.v2.asked", data: permission("permission-1") }) + harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } }) + harness.emit({ id: "event-2", created: 0, type: "permission.v2.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([formNotification, permissionNotification]) }) @@ -152,23 +159,25 @@ describe("internal notifications TUI plugin", () => { test("dedupes pending forms and permissions until they are resolved", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1") } }) - harness.emit({ id: "event-2", type: "form.created", data: { form: form("form-1") } }) + harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } }) + harness.emit({ id: "event-2", created: 0, type: "form.created", data: { form: form("form-1") } }) harness.emit({ id: "event-3", + created: 0, type: "form.replied", data: { sessionID: "session", id: "form-1", answer: {} }, }) - harness.emit({ id: "event-4", type: "form.created", data: { form: form("form-1") } }) + harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } }) - harness.emit({ id: "event-5", type: "permission.v2.asked", data: permission("permission-1") }) - harness.emit({ id: "event-6", type: "permission.v2.asked", data: permission("permission-1") }) + harness.emit({ id: "event-5", created: 0, type: "permission.v2.asked", data: permission("permission-1") }) + harness.emit({ id: "event-6", created: 0, type: "permission.v2.asked", data: permission("permission-1") }) harness.emit({ id: "event-7", + created: 0, type: "permission.v2.replied", data: { sessionID: "session", requestID: "permission-1", reply: "once" }, }) - harness.emit({ id: "event-8", type: "permission.v2.asked", data: permission("permission-1") }) + harness.emit({ id: "event-8", created: 0, type: "permission.v2.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([ formNotification, @@ -198,7 +207,7 @@ describe("internal notifications TUI plugin", () => { test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1", "subagent") } }) + harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "subagent") } }) harness.emit(stepStarted("event-2", "subagent")) harness.emit(executionSettled("event-3", "subagent")) @@ -241,12 +250,14 @@ describe("internal notifications TUI plugin", () => { harness.emit(stepStarted("event-1", "abort")) harness.emit({ id: "event-2", + created: 0, type: "session.error", data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, }) harness.emit(stepStarted("event-3", "timeout")) harness.emit({ id: "event-4", + created: 0, type: "session.error", data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, }) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e3e253cf2f..3d2a2a1d88 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2,6 +2,8 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" import type { V2Event } from "@opencode-ai/sdk/v2" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { EventV2 } from "@opencode-ai/core/event" import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" @@ -21,6 +23,10 @@ function emitEvent(events: ReturnType, event: V2Event) events.emit({ ...event, location: { directory } }) } +function durable(sessionID: string, seq = 0, version = 1) { + return { aggregateID: sessionID, seq, version } +} + test("refreshes resources into reactive getters", async () => { const events = createEventStream() const location = { @@ -43,8 +49,8 @@ test("refreshes resources into reactive getters", async () => { if (url.pathname === "/api/session/ses_test/message") return json({ data: [ - { id: "msg_second", type: "user", text: "Second", time: { created: 2 } }, - { id: "msg_first", type: "user", text: "First", time: { created: 1 } }, + { id: "msg_second", created: 0, type: "user", text: "Second", time: { created: 2 } }, + { id: "msg_first", created: 0, type: "user", text: "First", time: { created: 1 } }, ], cursor: {}, }) @@ -183,7 +189,9 @@ test("connectedOnce is false until first connect and persists across disconnect" ) const connect = () => stream?.enqueue( - encoder.encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`), + encoder.encode( + `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n`, + ), ) const disconnect = () => { stream?.close() @@ -260,11 +268,12 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_started", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("session-live"), data: { sessionID: "session-live", assistantMessageID: "message-live", - timestamp: 1, agent: "build", model: { id: "model", providerID: "provider" }, }, @@ -273,11 +282,12 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_ended", - type: "session.next.step.ended", + created: 0, + type: "step.ended", + durable: durable("session-live", 1, 2), data: { sessionID: "session-live", assistantMessageID: "message-live", - timestamp: 2, finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -291,10 +301,10 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_execution_settled", - type: "session.next.execution.settled", + created: 0, + type: "execution.settled", data: { sessionID: "session-live", - timestamp: 3, outcome: "success", }, }) @@ -302,11 +312,12 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_failed_step_started", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("session-failed"), data: { sessionID: "session-failed", assistantMessageID: "message-failed", - timestamp: 3, agent: "build", model: { id: "model", providerID: "provider" }, }, @@ -315,11 +326,12 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_failed", - type: "session.next.step.failed", + created: 0, + type: "step.failed", + durable: durable("session-failed", 1, 2), data: { sessionID: "session-failed", assistantMessageID: "message-failed", - timestamp: 4, error: { type: "unknown", message: "Provider unavailable" }, }, }) @@ -331,10 +343,10 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_failed_execution_settled", - type: "session.next.execution.settled", + created: 0, + type: "execution.settled", data: { sessionID: "session-failed", - timestamp: 5, outcome: "failure", error: { type: "unknown", message: "Provider unavailable" }, }, @@ -403,7 +415,7 @@ test("refreshes integrations after integration updates", async () => { expect(data.location.integration.list()).toEqual([]) const before = { ...requests } - emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} }) + emitEvent(events, { id: "evt_integration", created: 0, type: "integration.updated", data: {} }) await wait(() => data.location.integration.list()?.length === 1) await wait(() => requests.model > before.model && requests.provider > before.provider) expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) @@ -441,7 +453,7 @@ test("refreshes effective catalog data after catalog updates", async () => { try { await wait(() => requests.model > 0 && requests.provider > 0) const before = { ...requests } - emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} }) + emitEvent(events, { id: "evt_catalog", created: 0, type: "catalog.updated", data: {} }) await wait(() => requests.model > before.model && requests.provider > before.provider) } finally { app.renderer.destroy() @@ -488,7 +500,7 @@ test("refreshes agents after agent updates", async () => { try { await wait(() => data.location.agent.list()?.[0]?.id === "build") - emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} }) + emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} }) await wait(() => data.location.agent.list()?.[0]?.id === "reviewer") } finally { app.renderer.destroy() @@ -533,7 +545,7 @@ test("refreshes references after updates", async () => { try { await mounted await wait(() => requests === 1) - emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} }) + emitEvent(events, { id: "evt_reference_1", created: 0, type: "reference.updated", data: {} }) await wait(() => data.location.reference.list()?.length === 1) expect(data.location.reference.list()?.[0]?.name).toBe("docs") } finally { @@ -548,7 +560,10 @@ test("keeps shell state scoped to location", async () => { if (url.pathname !== "/api/shell") return const requestDirectory = url.searchParams.get("location[directory]") return json({ - location: { directory: requestDirectory ?? directory, project: { id: "proj_test", directory: requestDirectory ?? directory } }, + location: { + directory: requestDirectory ?? directory, + project: { id: "proj_test", directory: requestDirectory ?? directory }, + }, data: [ { id: requestDirectory === other ? "sh_other" : "sh_default", @@ -591,6 +606,7 @@ test("keeps shell state scoped to location", async () => { events.emit({ id: "evt_shell_created", + created: 0, type: "shell.created", location: { directory: other }, data: { @@ -639,6 +655,7 @@ test("adds and dismisses permission requests from live events", async () => { await wait(() => data.connection.status() === "connected") emitEvent(events, { id: "evt_permission_asked_1", + created: 0, type: "permission.v2.asked", data: { id: "per_1", @@ -649,6 +666,7 @@ test("adds and dismisses permission requests from live events", async () => { }) emitEvent(events, { id: "evt_permission_asked_2", + created: 0, type: "permission.v2.asked", data: { id: "per_2", @@ -661,6 +679,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_replied_1", + created: 0, type: "permission.v2.replied", data: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, }) @@ -669,6 +688,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_replied_2", + created: 0, type: "permission.v2.replied", data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, }) @@ -709,47 +729,52 @@ test("settles pending tools when a live failure arrives", async () => { await mounted emitEvent(events, { id: "evt_agent_1", - type: "session.next.agent.switched", - data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + created: 0, + type: "agent.selected", + durable: durable("session-1"), + data: { sessionID: "session-1", agent: "build" }, }) emitEvent(events, { id: "evt_model_1", - type: "session.next.model.switched", + created: 0, + type: "model.selected", + durable: durable("session-1", 1), data: { sessionID: "session-1", - messageID: "msg_model_1", - timestamp: 0, model: { id: "model-1", providerID: "provider-1" }, }, }) emitEvent(events, { id: "evt_step_started_1", - type: "session.next.step.started", + created: 0, + type: "step.started", + durable: durable("session-1", 2), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", - timestamp: 1, agent: "build", model: { id: "model-1", providerID: "provider-1" }, }, }) emitEvent(events, { id: "evt_input_1", - type: "session.next.tool.input.started", + created: 0, + type: "tool.input.started", + durable: durable("session-1", 3), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", - timestamp: 2, callID: "call-1", name: "bash", }, }) emitEvent(events, { id: "evt_called_1", - type: "session.next.tool.called", + created: 0, + type: "tool.called", + durable: durable("session-1", 4), data: { sessionID: "session-1", - timestamp: 2, assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", tool: "bash", @@ -759,10 +784,11 @@ test("settles pending tools when a live failure arrives", async () => { }) emitEvent(events, { id: "evt_failed_1", - type: "session.next.tool.failed", + created: 0, + type: "tool.failed", + durable: durable("session-1", 5), data: { sessionID: "session-1", - timestamp: 3, assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", error: { type: "unknown", message: "aborted" }, @@ -848,11 +874,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr const unsubscribe = sync.listen((event) => received.push(event.name)) emitEvent(events, { id: "evt_admitted_1", - type: "session.next.prompt.admitted", + created: 0, + type: "prompt.admitted", + durable: durable(sessionID), data: { sessionID, - messageID, - timestamp: 0, + inputID: messageID, prompt: { text: "hello" }, delivery: "steer", }, @@ -866,18 +893,17 @@ test("renders admitted prompts immediately with queued marker and clears when pr emitEvent(events, { id: "evt_prompted_1", - type: "session.next.prompted", + created: 0, + type: "prompt.promoted", + durable: durable(sessionID, 1), data: { sessionID, - messageID, - timestamp: 0, - prompt: { text: "hello" }, - delivery: "steer", + inputID: messageID, }, }) - await wait(() => received.at(-1) === "session.next.prompted") - expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"]) + await wait(() => received.at(-1) === "prompt.promoted") + expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"]) unsubscribe() const message = sync.session.message.list(sessionID)?.[0] expect(message?.type).toBe("user") @@ -925,20 +951,21 @@ test("projects live context updates with their message ID", async () => { await mounted emitEvent(events, { id: "evt_context_1", - type: "session.next.context.updated", + created: 0, + type: "session.context.updated", + durable: durable("session-1"), data: { sessionID: "session-1", - messageID: "msg_context_1", - timestamp: 1, text: "Updated context", }, }) await wait(() => sync.session.message.list("session-1")?.length === 1) expect(sync.session.message.list("session-1")?.[0]).toMatchObject({ - id: "msg_context_1", + id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")), type: "system", text: "Updated context", + time: { created: 0 }, }) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 6ba38a83b8..7a4465c927 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -30,6 +30,7 @@ function event(payload: V2Event, input: { directory: string; project?: string; w function vcs(branch: string): V2Event { return { id: `evt_vcs_${branch}`, + created: 0, type: "vcs.branch.updated", data: { branch, @@ -40,6 +41,7 @@ function vcs(branch: string): V2Event { function update(version: string): V2Event { return { id: `evt_update_${version}`, + created: 0, type: "installation.update-available", data: { version, diff --git a/script/ast-grep/rule-tests/__snapshots__/no-drizzle-column-name-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-drizzle-column-name-snapshot.yml new file mode 100644 index 0000000000..aae0c580ee --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-drizzle-column-name-snapshot.yml @@ -0,0 +1,12 @@ +id: no-drizzle-column-name +snapshots: + ? | + const table = sqliteTable("session", { + projectID: text("project_id").notNull(), + createdAt: integer("time_created").notNull(), + }) + : labels: + - source: text("project_id") + style: primary + start: 52 + end: 70 diff --git a/script/ast-grep/rule-tests/__snapshots__/no-effect-die-string-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-effect-die-string-snapshot.yml new file mode 100644 index 0000000000..a1bb464164 --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-effect-die-string-snapshot.yml @@ -0,0 +1,30 @@ +id: no-effect-die-string +snapshots: + Effect.die("boom"): + labels: + - source: Effect.die("boom") + style: primary + start: 0 + end: 18 + - source: '"boom"' + style: secondary + start: 11 + end: 17 + - source: ("boom") + style: secondary + start: 10 + end: 18 + Effect.die(`boom ${value}`): + labels: + - source: Effect.die(`boom ${value}`) + style: primary + start: 0 + end: 27 + - source: '`boom ${value}`' + style: secondary + start: 11 + end: 26 + - source: (`boom ${value}`) + style: secondary + start: 10 + end: 27 diff --git a/script/ast-grep/rule-tests/__snapshots__/no-import-alias-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-import-alias-snapshot.yml new file mode 100644 index 0000000000..6336c0bd01 --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-import-alias-snapshot.yml @@ -0,0 +1,42 @@ +id: no-import-alias +snapshots: + import { baz, foo as bar } from "./foo": + labels: + - source: foo as bar + style: primary + start: 14 + end: 24 + - source: bar + style: secondary + start: 21 + end: 24 + import { foo as bar } from "./foo": + labels: + - source: foo as bar + style: primary + start: 9 + end: 19 + - source: bar + style: secondary + start: 16 + end: 19 + import { foo as bar, baz } from "./foo": + labels: + - source: foo as bar + style: primary + start: 9 + end: 19 + - source: bar + style: secondary + start: 16 + end: 19 + import { type Foo as Bar, baz } from "./foo": + labels: + - source: type Foo as Bar + style: primary + start: 9 + end: 24 + - source: Bar + style: secondary + start: 21 + end: 24 diff --git a/script/ast-grep/rule-tests/__snapshots__/no-json-parse-cast-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-json-parse-cast-snapshot.yml new file mode 100644 index 0000000000..0aa2a274ea --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-json-parse-cast-snapshot.yml @@ -0,0 +1,8 @@ +id: no-json-parse-cast +snapshots: + const value = JSON.parse(input) as Record: + labels: + - source: JSON.parse(input) as Record + style: primary + start: 14 + end: 58 diff --git a/script/ast-grep/rule-tests/__snapshots__/no-nested-effect-service-yield-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-nested-effect-service-yield-snapshot.yml new file mode 100644 index 0000000000..30619694f8 --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-nested-effect-service-yield-snapshot.yml @@ -0,0 +1,20 @@ +id: no-nested-effect-service-yield +snapshots: + ? | + Effect.gen(function* () { + yield* (yield* Foo.Service).client.run() + }) + : labels: + - source: (yield* Foo.Service).client.run() + style: primary + start: 35 + end: 68 + ? | + Effect.gen(function* () { + yield* (yield* Foo.Service).run() + }) + : labels: + - source: (yield* Foo.Service).run() + style: primary + start: 35 + end: 61 diff --git a/script/ast-grep/rule-tests/__snapshots__/no-star-import-snapshot.yml b/script/ast-grep/rule-tests/__snapshots__/no-star-import-snapshot.yml new file mode 100644 index 0000000000..dab38e82f6 --- /dev/null +++ b/script/ast-grep/rule-tests/__snapshots__/no-star-import-snapshot.yml @@ -0,0 +1,22 @@ +id: no-star-import +snapshots: + import * as Foo from "./foo": + labels: + - source: '* as Foo' + style: primary + start: 7 + end: 15 + - source: import * as Foo from "./foo" + style: secondary + start: 0 + end: 28 + import type * as Foo from "./foo": + labels: + - source: '* as Foo' + style: primary + start: 12 + end: 20 + - source: import type * as Foo from "./foo" + style: secondary + start: 0 + end: 33 diff --git a/script/ast-grep/rule-tests/no-drizzle-column-name-test.yml b/script/ast-grep/rule-tests/no-drizzle-column-name-test.yml new file mode 100644 index 0000000000..00e1a7bb28 --- /dev/null +++ b/script/ast-grep/rule-tests/no-drizzle-column-name-test.yml @@ -0,0 +1,14 @@ +id: no-drizzle-column-name +valid: + - | + const table = sqliteTable("session", { + project_id: text().notNull(), + time_created: integer().notNull(), + payload: text({ mode: "json" }), + }) +invalid: + - | + const table = sqliteTable("session", { + projectID: text("project_id").notNull(), + createdAt: integer("time_created").notNull(), + }) diff --git a/script/ast-grep/rule-tests/no-effect-die-string-test.yml b/script/ast-grep/rule-tests/no-effect-die-string-test.yml new file mode 100644 index 0000000000..4ca9e737c0 --- /dev/null +++ b/script/ast-grep/rule-tests/no-effect-die-string-test.yml @@ -0,0 +1,7 @@ +id: no-effect-die-string +valid: + - Effect.die(new Error("boom")) + - Effect.fail("boom") +invalid: + - Effect.die("boom") + - Effect.die(`boom ${value}`) diff --git a/script/ast-grep/rule-tests/no-import-alias-test.yml b/script/ast-grep/rule-tests/no-import-alias-test.yml new file mode 100644 index 0000000000..7c3ab9235b --- /dev/null +++ b/script/ast-grep/rule-tests/no-import-alias-test.yml @@ -0,0 +1,13 @@ +id: no-import-alias +valid: + - import { foo } from "./foo" + - import type { Foo } from "./foo" + - import foo from "./foo" + - export { foo as bar } from "./foo" + - import type { Plugin as EffectPlugin } from "./foo" + - import type { Foo as Bar, Baz } from "./foo" +invalid: + - import { foo as bar } from "./foo" + - import { baz, foo as bar } from "./foo" + - import { foo as bar, baz } from "./foo" + - import { type Foo as Bar, baz } from "./foo" diff --git a/script/ast-grep/rule-tests/no-json-parse-cast-test.yml b/script/ast-grep/rule-tests/no-json-parse-cast-test.yml new file mode 100644 index 0000000000..ee44819ce8 --- /dev/null +++ b/script/ast-grep/rule-tests/no-json-parse-cast-test.yml @@ -0,0 +1,6 @@ +id: no-json-parse-cast +valid: + - const value = JSON.parse(input) + - const value = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(input) +invalid: + - const value = JSON.parse(input) as Record diff --git a/script/ast-grep/rule-tests/no-nested-effect-service-yield-test.yml b/script/ast-grep/rule-tests/no-nested-effect-service-yield-test.yml new file mode 100644 index 0000000000..e126f2d68a --- /dev/null +++ b/script/ast-grep/rule-tests/no-nested-effect-service-yield-test.yml @@ -0,0 +1,21 @@ +id: no-nested-effect-service-yield +valid: + - | + Effect.gen(function* () { + const service = yield* Foo.Service + yield* service.run() + }) + - | + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db.run() + }) +invalid: + - | + Effect.gen(function* () { + yield* (yield* Foo.Service).run() + }) + - | + Effect.gen(function* () { + yield* (yield* Foo.Service).client.run() + }) diff --git a/script/ast-grep/rule-tests/no-star-import-test.yml b/script/ast-grep/rule-tests/no-star-import-test.yml new file mode 100644 index 0000000000..6006f02140 --- /dev/null +++ b/script/ast-grep/rule-tests/no-star-import-test.yml @@ -0,0 +1,8 @@ +id: no-star-import +valid: + - import { Foo } from "./foo" + - import Foo from "./foo" + - export * as Foo from "./foo" +invalid: + - import * as Foo from "./foo" + - import type * as Foo from "./foo" diff --git a/script/ast-grep/rules/no-drizzle-column-name.yml b/script/ast-grep/rules/no-drizzle-column-name.yml new file mode 100644 index 0000000000..5b798b81ab --- /dev/null +++ b/script/ast-grep/rules/no-drizzle-column-name.yml @@ -0,0 +1,22 @@ +id: no-drizzle-column-name +language: TypeScript +message: Use snake_case object keys instead of explicit drizzle column names. +severity: error +files: + - packages/core/src/**/sql.ts + - packages/core/src/**/*.sql.ts +rule: + any: + - pattern: text($NAME) + - pattern: text($NAME, $$$ARGS) + - pattern: integer($NAME) + - pattern: integer($NAME, $$$ARGS) + - pattern: blob($NAME) + - pattern: blob($NAME, $$$ARGS) + - pattern: real($NAME) + - pattern: real($NAME, $$$ARGS) + - pattern: numeric($NAME) + - pattern: numeric($NAME, $$$ARGS) +constraints: + NAME: + kind: string diff --git a/script/ast-grep/rules/no-effect-die-string.yml b/script/ast-grep/rules/no-effect-die-string.yml new file mode 100644 index 0000000000..77265afb29 --- /dev/null +++ b/script/ast-grep/rules/no-effect-die-string.yml @@ -0,0 +1,22 @@ +id: no-effect-die-string +language: TypeScript +message: die with `new Error(...)`. +severity: error +rule: + any: + - all: + - pattern: Effect.die($MESSAGE) + - has: + field: arguments + all: + - kind: arguments + - has: + kind: string + - all: + - pattern: Effect.die($MESSAGE) + - has: + field: arguments + all: + - kind: arguments + - has: + kind: template_string diff --git a/script/ast-grep/rules/no-import-alias.yml b/script/ast-grep/rules/no-import-alias.yml new file mode 100644 index 0000000000..1c6c4c5298 --- /dev/null +++ b/script/ast-grep/rules/no-import-alias.yml @@ -0,0 +1,14 @@ +id: no-import-alias +language: TypeScript +message: Do not alias value imports. For type name collisions, alias inside a dedicated `import type` statement. +severity: error +rule: + all: + - kind: import_specifier + - has: + field: alias + kind: identifier + - not: + inside: + pattern: import type { $$$SPECS } from "$MOD" + stopBy: end diff --git a/script/ast-grep/rules/no-json-parse-cast.yml b/script/ast-grep/rules/no-json-parse-cast.yml new file mode 100644 index 0000000000..923528d880 --- /dev/null +++ b/script/ast-grep/rules/no-json-parse-cast.yml @@ -0,0 +1,6 @@ +id: no-json-parse-cast +language: TypeScript +message: Prefer Effect Schema JSON decoding over JSON.parse casts. +severity: error +rule: + pattern: JSON.parse($INPUT) as $TYPE diff --git a/script/ast-grep/rules/no-nested-effect-service-yield.yml b/script/ast-grep/rules/no-nested-effect-service-yield.yml new file mode 100644 index 0000000000..aaa6107a48 --- /dev/null +++ b/script/ast-grep/rules/no-nested-effect-service-yield.yml @@ -0,0 +1,11 @@ +id: no-nested-effect-service-yield +language: TypeScript +message: Bind Effect services before calling methods instead of nesting service yields. +severity: error +rule: + any: + - pattern: (yield* $SERVICE).$METHOD($$$ARGS) + - pattern: (yield* $SERVICE).$PROPERTY.$METHOD($$$ARGS) +constraints: + SERVICE: + regex: \.Service$ diff --git a/script/ast-grep/rules/no-star-import.yml b/script/ast-grep/rules/no-star-import.yml new file mode 100644 index 0000000000..e804223108 --- /dev/null +++ b/script/ast-grep/rules/no-star-import.yml @@ -0,0 +1,10 @@ +id: no-star-import +language: TypeScript +message: Do not use star imports. +severity: error +rule: + all: + - kind: namespace_import + - inside: + kind: import_statement + stopBy: end diff --git a/script/ast-grep/sgconfig.yml b/script/ast-grep/sgconfig.yml new file mode 100644 index 0000000000..098ecb2af4 --- /dev/null +++ b/script/ast-grep/sgconfig.yml @@ -0,0 +1,4 @@ +ruleDirs: + - rules +testConfigs: + - testDir: rule-tests diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 794db93c95..466cc73a8a 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,25 @@ # V2 Schema Changelog +## 2026-07-03: Require Durable Envelope On Durable Events + +- Make the wire `durable` envelope required on durable event definitions. +- Remove the `durable` envelope field from live-only event definitions. + +Compatibility: + +- No stored event row, database, or runtime publish behavior change; runtime already attaches the envelope only after durable commit/replay. +- Generated clients now model the existing invariant: durable events carry `durable`, live-only events do not. + +## 2026-07-03: Declare Event Durability At Definition Level + +- Add explicit `Event.durable(...)` and `Event.ephemeral(...)` definition constructors. +- Preserve the existing durable and live-only event classifications while deriving durable inventories from definition metadata instead of hand-maintained lists. + +Compatibility: + +- No wire payload, stored event row, database, or behavior change. +- Generated clients were regenerated from the unchanged public event schemas. + ## 2026-07-02: Rename Session Log Replay Marker - Rename the replay boundary marker from `log.caught_up` / `EventLog.CaughtUp` to `log.synced` / `EventLog.Synced`. @@ -884,3 +904,17 @@ Compatibility: - Existing Context Epoch rows migrate in place by dropping the obsolete selection and pending-replacement columns. - Model and agent switches no longer discard earlier chronological System Context updates by forcing a new baseline. + +## 2026-07-03: Normalize Session Event Names And Envelope Time + +- Drop the experimental `session.next.` prefix from current Session event names. +- Rename `agent.switched` to `agent.selected`, `model.switched` to `model.selected`, and `prompted` to `prompt.promoted`; add `prompt.admitted` as the durable prompt admission record. +- Add an envelope-level `created` timestamp stored on each event row; payload-level `timestamp` fields are removed. +- Remove projection-only `messageID` fields from selected/message-producing events; projected message IDs derive from the event ID. `revert.committed.messageID` remains, and `forked.messageID` is now `forked.from`. +- Rename `session.moved.subdirectory` to `subpath` and normalize event-related schema identifiers. + +Compatibility: + +- V2 durable events and projections are experimental and are reset by `20260703090000_reset_v2_event_rename_sweep`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped. +- All renamed durable event types restart at version 1 under their normalized names. +- Generated Promise, Effect, and legacy JavaScript SDK surfaces were regenerated from the normalized schemas. diff --git a/specs/v2/session.md b/specs/v2/session.md index 15bc970b47..b4b91ace09 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -42,12 +42,12 @@ Execution routing starts from only the Session ID: SessionExecution.resume(sessionID) -> SessionStore.get(sessionID) -> LocationServiceMap.get(session.location) --> SessionRunner.run({ sessionID, force? }) +-> SessionRunner.drain({ sessionID, force? }) ``` `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across steps. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. @@ -55,7 +55,7 @@ Projected hosted tools preserve call-side and settlement-side provider metadata V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch stores one immutable provider-cache baseline and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. -The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. +The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later steps, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. ```text Client Runner System Context Registry Context Epoch Store Session History LLM @@ -79,7 +79,7 @@ Client Runner System Context Registry C │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` -Agent and model selection are provider-turn scoped. A switch admitted after the current safe provider-turn boundary applies to the next provider turn without restarting the current turn or replacing the baseline. Agent-specific skill guidance remains a Context Source, so changed guidance is admitted as a chronological System message. A completed compaction causes the next provider attempt to render a fresh baseline directly from current complete context. A Session move clears the epoch so the destination Location initializes a complete baseline on its next run. +Agent and model selection are step-scoped. A switch admitted after the current safe step boundary applies to the next step without restarting the current step or replacing the baseline. Agent-specific skill guidance remains a Context Source, so changed guidance is admitted as a chronological System message. A completed compaction causes the next physical attempt to render a fresh baseline directly from current complete context. A Session move clears the epoch so the destination Location initializes a complete baseline on its next run. ```text Session Epoch @@ -110,15 +110,15 @@ Current Context Epoch follow-ups: ## Automatic Compaction -Before each provider turn, the runner estimates the complete model-visible request and compares it with the selected model's context window minus absolute reserved headroom. The reserve is the greater of the requested/model output allowance and configured `compaction.buffer`. When the request exceeds that budget and older complete turns are available, the runner compacts before executing the pending turn. +Before each step, the runner estimates the complete model-visible request and compares it with the selected model's context window minus absolute reserved headroom. The reserve is the greater of the requested/model output allowance and configured `compaction.buffer`. When the request exceeds that budget and older complete steps are available, the runner compacts before executing the pending step. Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. -`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. +`compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. -Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. +Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step. -When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical provider turn with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. +When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. ## V1 Runtime Context Parity @@ -131,18 +131,18 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Durable Context Source | Environment facts and host-local date | partial | Add selected provider/model identity without making model selection a stale Location-wide value. | | Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | | Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | -| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe provider-turn boundary. | +| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe step boundary. | | Durable Context Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization. | -| Per-turn request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | -| Per-turn request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | -| Per-turn request assembly | Provider/model-specific base instructions | complete | Native V2 selects the provider-family baseline unless the effective agent overrides it. | -| Per-turn request assembly | Policy-filtered built-in, MCP, plugin, and structured-output tools | partial | Materialize definitions for the effective agent and request. | -| Per-turn request assembly | Per-prompt system text and tool overrides | missing | Design admission and durable replay semantics before exposing them. | -| Per-turn request assembly | Steering, plan/build-switch, and final-step reminders | missing | Add only reminders whose behavior remains part of V2. | -| Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | -| Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | -| Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | -| Per-turn request assembly | Automatic/context-pressure compaction | complete | V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint. | +| Step request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | +| Step request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. | +| Step request assembly | Provider/model-specific base instructions | complete | Native V2 selects the provider-family baseline unless the effective agent overrides it. | +| Step request assembly | Policy-filtered built-in, MCP, plugin, and structured-output tools | partial | Materialize definitions for the effective agent and request. | +| Step request assembly | Per-prompt system text and tool overrides | missing | Design admission and durable replay semantics before exposing them. | +| Step request assembly | Steering, plan/build-switch, and final-step reminders | missing | Add only reminders whose behavior remains part of V2. | +| Step request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | +| Step request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | +| Step request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | +| Step request assembly | Automatic/context-pressure compaction | complete | V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint. | | Prompt/reference expansion | Durable typed prompt attachments | complete | None. | | Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. | | Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. | @@ -154,25 +154,25 @@ Provider timeout, retry, and watchdog policy is intentionally deferred. The runn Inbox delivery is explicit: -- `steer` inputs promote at the next safe provider-turn boundary, including continuation inside the current drain. +- `steer` inputs promote at the next safe step boundary, including continuation inside the current drain. - `queue` inputs remain in a FIFO while the current drain requires continuation. When the Session would otherwise become idle, the runner promotes exactly one queued input, then reevaluates continuation before promoting another. Execution has two entry points: -- `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt. +- `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a physical attempt. - `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input. Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need. -A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location. +A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart. Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. -Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. +Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per step. Before broadening exposure, revisit per-step call limits, output truncation, and operational backpressure using observed workloads. The normalized Session event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. -The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.log({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. +The normalized Session event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs differ; event time is carried by the envelope `created` field rather than duplicated in payloads. Consumers can use `sessions.log({ sessionID, after? })` to replay durable Session events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. The first `sessions.log(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. @@ -209,7 +209,7 @@ The first V2 `apply_patch` leaf supports add, update, and delete hunks. It parse ### Current Runner Follow-Ups -- Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after provider-turn consumption, persist every result, and reload history once before continuation. +- Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after step consumption, persist every result, and reload history once before continuation. - Buffer or coalesce streamed deltas before rewriting growing assistant projections. - Revisit additional covering indexes as larger-history query shapes become concrete. - Design any global multi-Session event stream separately; the finite history API deliberately reads one authorized Session aggregate and does not change global Event publication. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index ee7f9dff2a..6f09684a1b 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -21,17 +21,17 @@ through legacy `SessionPrompt.loop(...)`: - process-global `SessionExecution.resume(sessionID)` discovers Location from the Session read model - cached Location-scoped `SessionRunner` resolves one supported catalog model - and issues one explicit `llm.stream(request)` provider turn at a time + and issues one explicit `llm.stream(request)` step at a time - durable V2 projections record text, reasoning, provider failures, tool calls, tool results, and assistant output - a scoped `ToolRegistry` advertises definitions and the first permission-checked `read` built-in -- local continuation reloads projected history, and promoting new user input resets the selected agent's configured provider-turn allowance +- local continuation reloads projected history, and promoting new user input resets the selected agent's configured step allowance - concurrent resumes for one Session join one process-local run while different Sessions remain concurrent Prompt admission now uses a durable `session_input` inbox rather than immediate -transcript projection. `steer` inputs promote at the next safe provider-turn +transcript projection. `steer` inputs promote at the next safe step boundary while the current drain requires continuation. `queue` inputs remain in a FIFO until the Session would otherwise become idle and then promote one at a time. @@ -39,12 +39,12 @@ Next reviewed slices: - preserve eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await every settlement after the - provider turn closes, then reload projected history once -- revisit per-turn tool-call limits, output truncation, and operational + step closes, then reload projected history once +- revisit per-step tool-call limits, output truncation, and operational backpressure before broadening exposure; eager local execution is deliberately unbounded in the current local slice while SQLite publication stays serialized - remove the public in-memory `@opencode-ai/llm` tool loop after replacing its - remaining one-turn native-adapter use with a narrow typed dispatcher + remaining single-step native-adapter use with a narrow typed dispatcher - batch streamed deltas and add covering context indexes - expose replayable Session event cursors over HTTP and the generated SDK where remote consumers need them - integrate the new Job service with V2 tool execution: support background @@ -56,14 +56,14 @@ Next reviewed slices: ### Deferred durable continuation recovery Do not infer that ambiguous provider work is safe to retry from an advisory wake. -The first inbox-driven runner intentionally omits outer provider-attempt markers +The first inbox-driven runner intentionally omits outer physical-attempt markers until they have a concrete consumer and a complete recovery policy. Design post-crash continuation recovery as one explicit slice. It should model: - promoted input and projected-history state - queued-input promotion and steering assignment -- provider-attempt preparation versus provider-dispatch ambiguity +- physical-attempt preparation versus provider-dispatch ambiguity - required post-tool continuation across process loss - explicit `retry` and `abandon` decisions for unknown outcomes - bounded automatic retry only where provider and tool idempotency make it safe diff --git a/specs/v2/tools.md b/specs/v2/tools.md index 4dc7bfac22..6be14d2f7b 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -148,7 +148,7 @@ Invalid input never invokes the tool. Invalid output never produces a successful `toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. -Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation. +Step materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation. ## Output Bounding @@ -176,7 +176,7 @@ Leaf tools translate only errors they deliberately classify as recoverable. Broa - **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner. - **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay. - **Captured execution:** registration changes cannot alter an invocation after effective lookup. -- **Stale rejection:** a call never executes a registration other than the one advertised for its provider turn. +- **Stale rejection:** a call never executes a registration other than the one advertised for its step. - **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy. ## Follow-Up