From bf3ae45439ccd97f2991c87457081648e75cff71 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:43:21 -0500 Subject: [PATCH 01/21] fix(core): clean up mcp event surface (#35221) --- packages/core/src/mcp/client.ts | 6 +++- packages/core/src/mcp/index.ts | 6 +++- packages/core/test/mcp.test.ts | 14 +++++++++ packages/schema/src/mcp-event.ts | 2 +- packages/schema/test/event-manifest.test.ts | 3 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 33 --------------------- 6 files changed, 28 insertions(+), 36 deletions(-) create mode 100644 packages/core/test/mcp.test.ts 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/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/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index f4221335e9..ae1e82656d 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -27,4 +27,4 @@ export const StatusChanged = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, StatusChanged) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 9cdf3a787a..4fa23e75f7 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -12,6 +12,7 @@ import { } 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" @@ -63,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) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d97eb59298..56cdccf087 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -89,7 +89,6 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged - | EventMcpBrowserOpenFailed | EventMcpStatusChanged | EventCommandExecuted | EventProjectUpdated @@ -1556,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" @@ -3177,7 +3168,6 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged - | McpBrowserOpenFailed | McpStatusChanged | CommandExecuted | ProjectUpdated @@ -6373,20 +6363,6 @@ export type McpToolsChanged = { } } -export type McpBrowserOpenFailed = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "mcp.browser.open.failed" - location?: LocationRef - data: { - mcpName: string - url: string - } -} - export type McpStatusChanged = { id: string created: number @@ -7493,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" From 34a08cbdb807a3e3bcdd43ac7f570a75df0a899d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 14:45:56 -0400 Subject: [PATCH 02/21] fix(core): tolerate missing models.dev temperature --- packages/core/src/models-dev.ts | 2 +- packages/core/test/models.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 8a3fb965bf..a88aebfe07 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -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([ 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) From ca861fdf43500ff7a59489b7aa01cb45b8780abb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 14:49:05 -0400 Subject: [PATCH 03/21] docs: consolidate session work-unit vocabulary (#35218) --- AGENTS.md | 5 +- CONTEXT.md | 61 +++++++++++++++---------- packages/core/src/session/runner/llm.ts | 6 ++- specs/v2/session.md | 46 +++++++++---------- specs/v2/todo.md | 16 +++---- specs/v2/tools.md | 4 +- 6 files changed, 79 insertions(+), 59 deletions(-) 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/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ba575b8146..12c73832c8 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -166,7 +166,7 @@ const layer = Layer.effect( { concurrency: "unbounded" }, ).pipe(Effect.map(SystemContext.combine)) - const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( + const runTurnAttempt = Effect.fn("SessionRunner.runTurnAttempt")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -423,6 +423,10 @@ 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 diff --git a/specs/v2/session.md b/specs/v2/session.md index bb04c21356..4abc7104fe 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -47,7 +47,7 @@ SessionExecution.resume(sessionID) `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. -`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 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,23 +154,23 @@ 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 normalized Session 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 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. @@ -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 From a644e0e7a095efcca87e6bbd079fc1acf86f0bf4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 14:57:40 -0400 Subject: [PATCH 04/21] sync --- packages/cli/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e515424183..64116b9b76 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,6 +47,7 @@ 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" }), From 50977dd4fe85e89802313741d643f2255236c80c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 15:13:37 -0400 Subject: [PATCH 05/21] server: emit logs for every HTTP request to help debug API traffic --- packages/cli/src/commands/handlers/serve.ts | 2 +- packages/server/src/routes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 8a6b1c2ceb..546c5de7c5 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -142,7 +142,7 @@ 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]))), ), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 2157dd0b04..18ab2878f7 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -97,4 +97,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))) From e66cbf36e97d02b25ca8794b4313e679ca411967 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 15:34:30 -0400 Subject: [PATCH 06/21] fix(core): constrain location services (#35228) --- packages/cli/src/commands/handlers/serve.ts | 3 ++- packages/core/src/location-services.ts | 8 ++++---- packages/sdk-next/src/opencode.ts | 5 ++++- packages/server/src/routes.ts | 2 ++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 546c5de7c5..8d263880c5 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -3,6 +3,7 @@ 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, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" @@ -144,7 +145,7 @@ function bind(hostname: string, port: number, password: string) { return Layer.build( 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/core/src/location-services.ts b/packages/core/src/location-services.ts index ffb7db2cf1..f0f2d63e7f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -22,7 +22,6 @@ 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" @@ -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/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/server/src/routes.ts b/packages/server/src/routes.ts index 18ab2878f7..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" @@ -33,6 +34,7 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, + Project.node, SessionV2.node, PluginRuntime.providerNode, PermissionSaved.node, From 5d14c7a18550e1f886caf5b14e6962ee22ad6bdf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 15:41:50 -0400 Subject: [PATCH 07/21] refactor(core): align runner naming with step vocabulary (#35227) --- .../core/src/session/context-checkpoint.ts | 2 +- packages/core/src/session/execution/local.ts | 2 +- packages/core/src/session/history.ts | 2 +- packages/core/src/session/instructions.ts | 4 +- packages/core/src/session/message-updater.ts | 2 +- packages/core/src/session/projector.ts | 5 ++- packages/core/src/session/runner/index.ts | 10 ++--- packages/core/src/session/runner/llm.ts | 44 +++++++++---------- .../src/session/runner/publish-llm-event.ts | 2 +- packages/core/src/system-context/index.ts | 2 +- .../core/test/session-runner-message.test.ts | 6 +-- .../core/test/session-runner-recorded.test.ts | 2 +- packages/core/test/session-runner.test.ts | 12 ++--- specs/v2/session.md | 2 +- 14 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/context-checkpoint.ts index fa3b24aedb..977514e8ca 100644 --- a/packages/core/src/session/context-checkpoint.ts +++ b/packages/core/src/session/context-checkpoint.ts @@ -18,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, diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 1a45537490..4e68e6c3d9 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -20,7 +20,7 @@ const layer = Layer.effect( drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( + return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) 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/instructions.ts b/packages/core/src/session/instructions.ts index a75074ae04..22f003e5ea 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -36,9 +36,9 @@ const layer = Layer.effect( // 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 = yield* fs.resolve(location.project.directory) - // Same-turn parallel reads settle concurrently, so an in-memory claim guards each + // 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: { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index b07d0d1234..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) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 62ad7ec2ce..c5b552e8b4 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -168,7 +168,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .get() .pipe(Effect.orDie) : undefined - if (event.data.from && !boundary) return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) + 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) @@ -357,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) 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 12c73832c8..0aeaa0e052 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -61,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. @@ -77,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 @@ -86,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( @@ -114,7 +114,7 @@ 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() @@ -166,7 +166,7 @@ const layer = Layer.effect( { concurrency: "unbounded" }, ).pipe(Effect.map(SystemContext.combine)) - const runTurnAttempt = Effect.fn("SessionRunner.runTurnAttempt")(function* ( + const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -177,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, @@ -231,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 = []) => @@ -282,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* () { @@ -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 }) { @@ -428,8 +428,8 @@ const layer = Layer.effect( // 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) @@ -445,7 +445,7 @@ const layer = Layer.effect( } }) - return Service.of({ run }) + return Service.of({ drain }) }), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 4b7b6fb92c..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, 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/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 840a4e424c..d33b44728d 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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 2768be820a..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, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ca8ec42775..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, @@ -575,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([ @@ -583,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" } } @@ -2983,7 +2983,7 @@ 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("step.failed.1") yield* session.interrupt(sessionID) @@ -3007,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 @@ -3018,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", diff --git a/specs/v2/session.md b/specs/v2/session.md index 4abc7104fe..b4b91ace09 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -42,7 +42,7 @@ 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. From 37b26e495ba857e0727e7124221e54f940b509d8 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 15:46:04 -0400 Subject: [PATCH 08/21] feat(simulation): share control protocol schemas (#35230) --- packages/simulation/package.json | 3 +- packages/simulation/src/backend/control.ts | 56 ++------ packages/simulation/src/frontend/actions.ts | 31 +---- packages/simulation/src/frontend/server.ts | 83 ++--------- packages/simulation/src/protocol/index.ts | 145 ++++++++++++++++++++ 5 files changed, 170 insertions(+), 148 deletions(-) create mode 100644 packages/simulation/src/protocol/index.ts diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 93bab0540e..29613ff28a 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -9,7 +9,8 @@ "./backend": "./src/backend/index.ts", "./backend/*": "./src/backend/*.ts", "./frontend": "./src/frontend/simulation.ts", - "./frontend/*": "./src/frontend/*.ts" + "./frontend/*": "./src/frontend/*.ts", + "./protocol": "./src/protocol/index.ts" }, "scripts": { "typecheck": "tsgo --noEmit" diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index f3fc67116c..10bded13af 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect" +import { Effect } from "effect" +import { SimulationProtocol } from "../protocol" import { SimulationLLMExchange } from "./llm-exchange" import { SimulationNetwork } from "./network" @@ -23,43 +24,13 @@ import { SimulationNetwork } from "./network" const DefaultPort = 40950 const MaxPortAttempts = 100 -const ChunkItem = 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.Unknown }), - Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Unknown }), -]) - -const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(ChunkItem) }) - -const FinishParams = Schema.Struct({ - id: Schema.String, - reason: Schema.Literals(["stop", "tool-calls", "length", "content-filter"]).pipe( - Schema.withDecodingDefault(Effect.succeed("stop" as const)), - ), -}) - -const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) -const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) - -type JsonRpcRequest = { - readonly jsonrpc: "2.0" - readonly id?: string | number | null - readonly method: string - readonly params?: unknown -} - type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> -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 parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(socket: ControlSocket, request: JsonRpcRequest): Promise { +async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { switch (request.method) { case "llm.attach": { socket.data.unsubscribe?.() @@ -69,7 +40,7 @@ async function handle(socket: ControlSocket, request: JsonRpcRequest): Promise 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/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" From 438654768c767d3d81b21d0c0181cf039e9d43d4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:55:03 -0500 Subject: [PATCH 09/21] fix(codemode): require exact tool paths in guidance (#35224) --- packages/codemode/src/tool-runtime.ts | 13 ++++---- packages/codemode/test/codemode.test.ts | 10 +++--- packages/codemode/test/signature.test.ts | 40 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) 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: {') + }) +}) From d097cc806508c6bdcfd3d9332a781d688c313ff4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:10:25 -0500 Subject: [PATCH 10/21] feat(tui): render execute child calls on v2 (#35231) --- packages/tui/src/routes/session/index.tsx | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d406fe664e..b0f337c2d4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1810,6 +1810,9 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { + + + @@ -2296,6 +2299,65 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin return `${formatSubagentToolcalls(toolcalls)} · ${duration}` } +type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record } + +function executeCalls(value: unknown): ExecuteCall[] { + if (!Array.isArray(value)) return [] + return value.flatMap((call) => { + const item = recordValue(call) + const tool = stringValue(item?.tool) + const status = stringValue(item?.status) + if (!tool || !status || !["running", "completed", "error"].includes(status)) return [] + return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }] + }) +} + +function Execute(props: ToolProps) { + const ctx = use() + const { theme } = useTheme() + const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running") + const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) + const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) + const hasRuntimeError = createMemo(() => props.metadata.error === true) + const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output) + const showOutput = createMemo(() => output() && hasRuntimeError()) + const content = createMemo(() => { + const lines = ["execute"] + for (const call of calls()) { + const args = input(call.input ?? {}) + lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`) + } + return lines.join("\n") + }) + + return ( + <> + + {content()} + + + + + {(line, index) => ( + + {index() === 0 ? "↳ " : " "} + {line} + + )} + + + + + ) +} + function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() @@ -2571,6 +2633,7 @@ const toolDisplays = new Set([ "write", "edit", "subagent", + "execute", "apply_patch", "todowrite", "question", From 64e4f6f91b951680320a6cbe07e059a550c95261 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 3 Jul 2026 22:47:04 +0200 Subject: [PATCH 11/21] cli: route run commands through v2 APIs (#35234) --- .../src/cli/cmd/run/footer.prompt.tsx | 17 +- .../opencode/src/cli/cmd/run/footer.view.tsx | 1 + packages/opencode/src/cli/cmd/run/runtime.ts | 4 +- .../src/cli/cmd/run/stream-v2.transport.ts | 378 +++++++++++++---- packages/opencode/src/cli/cmd/run/types.ts | 2 + .../test/cli/run/footer.view.test.tsx | 49 ++- .../test/cli/run/stream-v2.transport.test.ts | 389 ++++++++++++++++++ 7 files changed, 763 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 90efdc5695..9d10a5266a 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -175,14 +175,18 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { return { type: "pending" as const } } - if (!commands.some((item) => item.name === head.name)) { + const item = commands.find((entry) => entry.name === head.name) + if (!item) { return { type: "none" as const } } - return { type: "command" as const, command: { name: head.name, arguments: head.arguments } } + return { + type: "command" as const, + command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) }, + } } -function selectedCommand(text: string, command: RunPrompt["command"]) { +export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) { if (!command) { return } @@ -192,9 +196,14 @@ function selectedCommand(text: string, command: RunPrompt["command"]) { return } + // Bound drafts (e.g. the skill picker) may predate or omit the catalog + // source; resolve it at submit time so routing never degrades to a plain + // command for a skill entry. + const source = command.source ?? commands?.find((item) => item.name === command.name)?.source return { name: command.name, arguments: head.arguments, + ...(source ? { source } : {}), } } @@ -1178,7 +1187,7 @@ export function createPromptState(input: PromptInput): PromptState { return } - const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) + const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands()) if (!command && next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 245a24816d..a7c1321494 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) { command: { name, arguments: "", + source: "skill", }, }) closePanel() diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 8f3704fd41..e340a89cc3 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -665,7 +665,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep (row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID, ) } - includeFiles = false + // Shell and skill turns never send CLI file attachments; keep them + // pending for the next prompt-shaped turn. + if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false } catch (error) { if (signal.aborted || footer.isClosed) { return 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 614a960634..c15ed48e5b 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -77,6 +77,15 @@ type Wait = { onVisibleOutput?: (anchor: LocalReplayAnchor) => void } +// One active session.shell call. The HTTP response is the completion signal; +// callID correlates the live shell events once shell.started is observed, and +// abort cancels the blocking request when the user interrupts the turn. +type ShellWait = { + callID?: string + resolve: () => void + abort: () => void +} + type RunV2Event = V2Event type PromptFilePart = Extract @@ -99,6 +108,11 @@ type State = { projectedReasoning: Map tools: Map finishedTools: Set + skillMessages: Set + shellCommands: Map + shellStarted: Set + shellEnded: Set + shellWait?: ShellWait wait?: Wait connected: boolean closed: boolean @@ -179,10 +193,71 @@ function promptFileSource(part: PromptFilePart) { } } +function promptFiles(next: SessionTurnInput) { + return next.prompt.parts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: promptFileSource(part), + }, + ] + : [], + ) +} + +function promptAgents(next: SessionTurnInput) { + return next.prompt.parts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source ? { start: part.source.start, end: part.source.end, text: part.source.value } : undefined, + }, + ] + : [], + ) +} + function streamPartKey(messageID: string, partID: string) { return `${messageID}\u0000${partID}` } +// Matches the commit shapes the legacy session-data reducer produced for direct +// shell calls: one "start" commit rendering `$ command` and one "progress" +// commit rendering the merged output (see toolEntryBody in tool.ts). +function shellCommit( + callID: string, + command: string, + next: { text: string; phase: "start" | "progress"; toolState: "running" | "completed" }, +): StreamCommit { + return { + kind: "tool", + source: "tool", + partID: `shell:${callID}`, + tool: "bash", + shell: { callID, command }, + ...next, + } +} + +// session.shell resolves after the command settled server-side; the matching +// live shell.ended event usually lands within the same tick, but hold the turn +// briefly so the output commit renders inside it. +const SHELL_OUTPUT_GRACE_MS = 1500 + +function skillCommit(messageID: string, name: string): StreamCommit { + return { + kind: "system", + source: "system", + messageID, + partID: `skill:${messageID}`, + text: `→ Skill "${name}"`, + phase: "start", + } +} + async function resolveSelectedModel(input: StreamInput, next: Pick) { if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } if (!next.variant) return @@ -213,6 +288,10 @@ export async function createSessionTransport(input: StreamInput): Promise { + if (state.wait || state.shellWait) throw new Error("prompt already running") + if (!state.connected) throw new Error("Event stream is reconnecting") + const abort = new AbortController() + const onAbort = () => abort.abort() + next.signal?.addEventListener("abort", onAbort, { once: true }) + let rendered!: () => void + const output = new Promise((resolve) => { + rendered = resolve + }) + const active: ShellWait = { resolve: rendered, abort: () => abort.abort() } + state.shellWait = active + input.trace?.write("send.shell", { sessionID: input.sessionID, command: next.prompt.text }) + write([], { phase: "running", status: "running shell" }) + try { + await input.sdk.v2.session.shell( + { sessionID: input.sessionID, command: next.prompt.text }, + { throwOnError: true, signal: abort.signal }, + ) + await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)]) + } catch (error) { + if (abort.signal.aborted) return + throw error + } finally { + next.signal?.removeEventListener("abort", onAbort) + if (state.shellWait === active) state.shellWait = undefined + } + } + + // Shared settlement scaffolding for prompt-shaped turns: registers the wait, + // wires interruption, sends, then blocks until the live settled event (or a + // hydration pass over an idle session) resolves it. + const runTurnWait = async ( + next: SessionTurnInput, + messageID: string, + turn: { promoted?: boolean; send: () => Promise }, + ) => { + let resolve!: () => void + let reject!: (error: unknown) => void + const done = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + const active: Wait = { + messageID, + promoted: turn.promoted === true, + interrupted: false, + failureRendered: false, + resolve, + reject, + onVisibleOutput: next.onVisibleOutput, + } + state.wait = active + const interrupt = () => { + active.interrupted = true + void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + next.signal?.addEventListener("abort", interrupt, { once: true }) + try { + await turn.send() + await done + } catch (error) { + if (state.wait === active) state.wait = undefined + if (next.signal?.aborted) return + throw error + } finally { + next.signal?.removeEventListener("abort", interrupt) + } + } + return { async runPromptTurn(next) { - if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts") - if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts") - if (state.wait) throw new Error("prompt already running") + if (next.prompt.mode === "shell") { + await runShellTurn(next) + return + } + if (state.wait || state.shellWait) throw new Error("prompt already running") if (!state.connected) throw new Error("Event stream is reconnecting") + const messageID = next.prompt.messageID + if (!messageID) throw new Error("Prompt message ID is required") + + const command = next.prompt.command + if (command?.source === "skill") { + input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.v2.session.skill( + { sessionID: input.sessionID, id: messageID, skill: command.name }, + { throwOnError: true, signal: next.signal }, + ), + }) + return + } + if (command) { + const selected = await resolveSelectedModel(input, next) + if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + // Agent and model ride the command payload; the server switches only + // when the command itself does not pin them. + const files = [ + ...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })), + ...promptFiles(next), + ] + const agents = promptAgents(next) + input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.v2.session.command( + { + sessionID: input.sessionID, + id: messageID, + command: command.name, + arguments: command.arguments, + agent: next.agent, + model: selected, + files: files.length ? files : undefined, + agents: agents.length ? agents : undefined, + delivery: "steer", + }, + { throwOnError: true, signal: next.signal }, + ), + }) + return + } if (next.agent) { await input.sdk.v2.session.switchAgent( @@ -695,78 +964,41 @@ export async function createSessionTransport(input: StreamInput): Promise - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: promptFileSource(part), + const attachments = [ + ...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), + ...promptFiles(next), + ] + const agents = promptAgents(next) + input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.v2.session.prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [ + next.prompt.text, + ...prepared.flatMap((file) => (file.text ? [file.text] : [])), + ].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, }, - ] - : [], - ) - const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles] - const agents = next.prompt.parts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ) - const messageID = next.prompt.messageID - if (!messageID) throw new Error("Prompt message ID is required") - let resolve!: () => void - let reject!: (error: unknown) => void - const done = new Promise((done, fail) => { - resolve = done - reject = fail - }) - const active: Wait = { - messageID, - promoted: false, - interrupted: false, - failureRendered: false, - resolve, - reject, - onVisibleOutput: next.onVisibleOutput, - } - state.wait = active - const interrupt = () => { - active.interrupted = true - void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) - } - next.signal?.addEventListener("abort", interrupt, { once: true }) - try { - input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) - await input.sdk.v2.session.prompt( - { - sessionID: input.sessionID, - id: messageID, - prompt: { - text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), - files: attachments.length ? attachments : undefined, - agents: agents.length ? agents : undefined, + delivery: "steer", }, - delivery: "steer", - }, - { throwOnError: true, signal: next.signal }, - ) - await done - } catch (error) { - if (state.wait === active) state.wait = undefined - if (next.signal?.aborted) return - throw error - } finally { - next.signal?.removeEventListener("abort", interrupt) - } + { throwOnError: true, signal: next.signal }, + ), + }) }, async interruptActiveTurn() { + // A running shell holds no drain, so session.interrupt cannot reach it; + // abort the blocking request instead. The server-side command keeps its + // own lifecycle and simply loses its waiter. + const shell = state.shellWait + if (shell) { + shell.abort() + return + } if (state.wait) state.wait.interrupted = true await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) }, @@ -787,6 +1019,10 @@ export async function createSessionTransport(input: StreamInput): Promise } }) +test("selectedCommand backfills the catalog source for bound drafts", () => { + const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })] + + // The skill picker binds `/name ` drafts; older drafts may lack source. + expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({ + name: "opencode-ts", + arguments: "fix it", + source: "skill", + }) + // An explicit source wins without a catalog lookup. + expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({ + name: "opencode-ts", + arguments: "", + source: "skill", + }) + // Plain commands stay untagged. + expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [ + command({ name: "deploy", description: "Deploy" }), + ])).toEqual({ name: "deploy", arguments: "prod" }) +}) + +test("direct footer tags skill slash submissions with their catalog source", async () => { + const submits: RunPrompt[] = [] + const app = await renderFooter({ + commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })], + onSubmit(prompt) { + submits.push(prompt) + return true + }, + }) + + try { + await app.renderOnce() + "/formatter src".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + expect(submits).toEqual([ + { text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } }, + ]) + } finally { + app.cleanup() + } +}) + // OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition. // Re-enable after the upstream renderer teardown fix lands. test.skip("direct footer skill picker inserts an editable bound skill command", async () => { @@ -864,7 +911,7 @@ test.skip("direct footer skill picker inserts an editable bound skill command", app.mockInput.pressEnter() await app.renderOnce() - expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }]) + expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } }]) } finally { app.cleanup() } 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 8bf429e500..e43e88f934 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -1002,6 +1002,395 @@ describe("V2 mini transport", () => { await transport.close() }) + test("runs a shell turn through v2.session.shell and renders live output", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + spyOn(client.v2.session, "shell").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_shell_start", + created: 0, + type: "shell.started", + durable: durable("ses_1"), + data: { sessionID: "ses_1", callID: "call_shell", command: "ls" }, + }) + events.push({ + id: "evt_shell_end", + created: 0, + type: "shell.ended", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", callID: "call_shell", output: "file.txt" }, + }) + }) + return ok(undefined) as never + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "ls", parts: [], mode: "shell" }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ sessionID: "ses_1", command: "ls" }) + expect(ui.commits.filter((item) => item.shell)).toMatchObject([ + { phase: "start", tool: "bash", toolState: "running", shell: { callID: "call_shell", command: "ls" } }, + { phase: "progress", text: "file.txt", toolState: "completed", shell: { callID: "call_shell", command: "ls" } }, + ]) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "running shell" } }) + await transport.close() + }) + + test("aborts an active shell turn without interrupting the session", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let started = false + let aborted = false + spyOn(client.v2.session, "shell").mockImplementation( + (_input, options) => + new Promise((_, reject) => { + started = true + options?.signal?.addEventListener("abort", () => { + aborted = true + reject(new Error("aborted")) + }) + }) as never, + ) + const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "sleep 100", parts: [], mode: "shell" }, + files: [], + includeFiles: true, + }) + while (!started) await Bun.sleep(0) + await transport.interruptActiveTurn() + await turn + + expect(aborted).toBe(true) + expect(interrupted).not.toHaveBeenCalled() + await transport.close() + }) + + test("hydrates projected shell transcripts once and dedupes live redelivery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_shell", + type: "shell" as const, + callID: "call_1", + command: "ls", + output: "file.txt", + time: { created: 1, completed: 2 }, + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_shell_end", + created: 0, + type: "shell.ended", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", callID: "call_1", output: "file.txt" }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + + expect(ui.commits.filter((item) => item.shell)).toMatchObject([ + { phase: "start", shell: { callID: "call_1", command: "ls" } }, + { phase: "progress", text: "file.txt", toolState: "completed" }, + ]) + await transport.close() + }) + + test("routes command prompts through v2.session.command", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + spyOn(client.v2.session, "command").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + created: 0, + type: "prompt.promoted", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + inputID: "msg_cmd", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_cmd", + sessionID: "ses_1", + prompt: { text: "evaluated template" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: "build", + model: { providerID: "test", modelID: "model" }, + variant: undefined, + prompt: { + messageID: "msg_cmd", + text: "/deploy prod", + parts: [], + command: { name: "deploy", arguments: "prod" }, + }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ + sessionID: "ses_1", + id: "msg_cmd", + command: "deploy", + arguments: "prod", + agent: "build", + model: { providerID: "test", id: "model" }, + delivery: "steer", + }) + // Selection rides the command payload; no separate client-side switch. + expect(client.v2.session.switchAgent).not.toHaveBeenCalled() + expect(client.v2.session.switchModel).not.toHaveBeenCalled() + await transport.close() + }) + + test("routes skill prompts through v2.session.skill and settles without promotion", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + const command = spyOn(client.v2.session, "command") + const prompt = spyOn(client.v2.session, "prompt") + spyOn(client.v2.session, "skill").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_skill", + created: 0, + type: "skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: input.skill ?? "tigerstyle", + text: "skill instructions", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok(undefined) as never + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_skill", + text: "/tigerstyle", + parts: [], + command: { name: "tigerstyle", arguments: "", source: "skill" }, + }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) + expect(command).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + expect(ui.commits).toContainEqual( + expect.objectContaining({ kind: "system", text: '→ Skill "tigerstyle"', messageID: "msg_skill" }), + ) + await transport.close() + }) + + test("does not resolve a skill turn before the matching activation is observed", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let sent = false + spyOn(client.v2.session, "skill").mockImplementation(() => { + sent = true + return ok(undefined) as never + }) + + let done = false + const turn = transport + .runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_skill", + text: "/tigerstyle", + parts: [], + command: { name: "tigerstyle", arguments: "", source: "skill" }, + }, + files: [], + includeFiles: true, + }) + .then(() => { + done = true + }) + while (!sent) await Bun.sleep(0) + events.push({ + id: "evt_unrelated_settled", + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + expect(done).toBe(false) + + events.push({ + id: "evt_skill", + created: 0, + type: "skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: "tigerstyle", + text: "skill instructions", + }, + }) + events.push({ + id: "evt_skill_settled", + created: 0, + type: "execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(done).toBe(true) + await transport.close() + }) + + test("hydrates skill activation messages once and dedupes live redelivery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_skill", + type: "skill" as const, + name: "tigerstyle", + text: "skill instructions", + time: { created: 2 }, + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_skill", + created: 0, + type: "skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: "tigerstyle", + text: "skill instructions", + }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + + expect(ui.commits.filter((item) => item.text === '→ Skill "tigerstyle"')).toHaveLength(1) + await transport.close() + }) + test("discovers a live child session and tracks its tab and selected detail", async () => { const events = feed() events.push(connected()) From 650d7743726dada59c30c56d0118783e34ad65ff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 17:30:25 -0400 Subject: [PATCH 12/21] refactor(schema): session shell payloads and event prefix restore (#35229) --- .../src/components/dialog-custom-provider.tsx | 2 +- .../app/src/components/settings-providers.tsx | 2 +- .../src/components/settings-v2/providers.tsx | 2 +- .../client/src/promise/generated/types.ts | 274 +++- packages/client/test/effect.test.ts | 10 +- packages/client/test/promise.test.ts | 4 +- packages/core/src/database/migration.gen.ts | 1 + ...703190000_reset_v2_shell_event_payloads.ts | 14 + packages/core/src/session.ts | 66 +- packages/core/src/session/compaction.ts | 2 +- packages/core/src/session/message-updater.ts | 106 +- packages/core/src/session/projector.ts | 21 +- .../core/src/session/runner/to-llm-message.ts | 2 +- packages/core/test/session-create.test.ts | 18 +- packages/core/test/session-log.test.ts | 8 +- packages/core/test/session-projector.test.ts | 31 +- packages/core/test/session-prompt.test.ts | 10 +- .../core/test/session-runner-message.test.ts | 16 +- .../core/test/session-runner-recorded.test.ts | 12 +- .../test/session-runner-tool-events.test.ts | 8 +- packages/core/test/session-runner.test.ts | 6 +- .../src/cli/cmd/run/noninteractive.ts | 30 +- .../opencode/src/cli/cmd/run/session-data.ts | 30 +- .../src/cli/cmd/run/stream-v2.subagent.ts | 30 +- .../src/cli/cmd/run/stream-v2.transport.ts | 125 +- .../test/cli/run/noninteractive.test.ts | 4 +- .../test/cli/run/session-data.test.ts | 12 +- .../test/cli/run/stream-v2.transport.test.ts | 139 +- .../opencode/test/server/httpapi-pty.test.ts | 4 +- .../opencode/test/server/httpapi-sdk.test.ts | 2 +- .../opencode/test/tool/apply_patch.test.ts | 4 +- .../test/tool/fixtures/models-api.json | 4 +- .../test/v2/session-message-updater.test.ts | 26 +- packages/schema/src/session-event.ts | 76 +- packages/schema/src/session-message.ts | 6 +- packages/schema/test/event-manifest.test.ts | 64 +- packages/sdk-next/test/embedded.test.ts | 12 +- packages/sdk/js/script/build.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1324 +++++++++-------- packages/sdk/openapi.json | 6 +- packages/tui/src/context/data.tsx | 77 +- .../feature-plugins/system/notifications.ts | 16 +- packages/tui/src/routes/session/index.tsx | 18 +- packages/tui/src/routes/session/rows.ts | 28 +- .../test/cli/cmd/tui/notifications.test.ts | 6 +- packages/tui/test/cli/tui/data.test.tsx | 32 +- packages/ui/src/components/provider-icon.tsx | 2 +- specs/v2/schema-changelog.md | 23 +- specs/v2/session.md | 2 +- 49 files changed, 1521 insertions(+), 1200 deletions(-) create mode 100644 packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 647e5002a2..dfc1bb8c22 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) { >
- +
{language.t("provider.custom.title")}
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 24e7a60104..ca6413e4ba 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => { >
- + {language.t("provider.custom.title")} {language.t("settings.providers.tag.custom")}
diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index cd24bbd455..e244581b97 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
event.type)).toEqual(["server.connected", "model.selected"]) + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"]) const durable = events[1] - if (durable?.type !== "model.selected") throw new Error("Expected model event") + if (durable?.type !== "session.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 }) }) @@ -159,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(["model.selected", "log.synced"]) - expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( + expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"]) + expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( 1_717_171_717_000, ) expect(logged.at(-1)).toEqual(synced) @@ -228,7 +228,7 @@ const modelSwitchedMessage = { const modelSwitchedEvent = { id: "evt_model", created: 1_717_171_717_000, - type: "model.selected", + type: "session.model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { sessionID: "ses_test", diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 9e8ea7acfe..d445a69c08 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -160,7 +160,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( for await (const event of client.event.subscribe()) events.push(event) 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) + expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000) }) test("event.subscribe terminates on malformed Promise SSE data", async () => { @@ -329,7 +329,7 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 } const modelSwitchedEvent = { id: "evt_model", created: 1_717_171_717_000, - type: "model.selected", + type: "session.model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { sessionID: "ses_test", diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 7956b64f4d..e6a236f527 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -43,5 +43,6 @@ export const migrations = ( import("./migration/20260702134641_add_session_context_entry"), import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703181610_event_created_column"), + import("./migration/20260703190000_reset_v2_shell_event_payloads"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts b/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts new file mode 100644 index 0000000000..ffbe40652c --- /dev/null +++ b/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703190000_reset_v2_shell_event_payloads", + 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\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 108d36707a..e8928a7520 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -41,8 +41,8 @@ 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 { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" export const RevertState = Revert.State @@ -272,19 +272,6 @@ 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() @@ -550,23 +537,38 @@ const layer = Layer.effect( Effect.gen(function* () { activeShells.add(input.sessionID) if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) - const callID = Identifier.ascending() + const started = yield* Effect.gen(function* () { + const shell = yield* Shell.Service + return yield* shell.create({ command: input.command, cwd: session.location.directory }) + }).pipe(Effect.provide(locations.get(session.location))) yield* events.publish( SessionEvent.Shell.Started, { sessionID: input.sessionID, - callID, - command: input.command, + shell: started, }, { id: input.id }, ) - const output = yield* runShellCommand(input.command, session.location.directory).pipe( - Effect.provide(locations.get(session.location)), - ) + const completed = yield* Effect.gen(function* () { + const shell = yield* Shell.Service + const terminal = yield* shell.wait(started.id).pipe( + Effect.map((info) => ({ info, retained: true as const })), + Effect.catchTag("Shell.NotFoundError", () => + Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }), + ), + ) + const output = terminal.retained + ? yield* shell + .output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES }) + .pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput()))) + : missingShellOutput() + return { shell: terminal.info, output } + }) + .pipe(Effect.provide(locations.get(session.location))) yield* events.publish(SessionEvent.Shell.Ended, { sessionID: input.sessionID, - callID, - output, + shell: completed.shell, + output: completed.output, }) }).pipe( Effect.ensuring( @@ -706,6 +708,26 @@ const layer = Layer.effect( }), ) +function missingShellOutput() { + const output = "Shell command output is no longer available." + return { + output, + cursor: Buffer.byteLength(output), + size: Buffer.byteLength(output), + truncated: false, + } +} + +function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info { + return { + ...started, + // The Shell record was removed before waiters could observe it; publish a terminal + // boundary instead of leaving the Session shell message permanently running. + status: "killed", + time: { ...started.time, completed: Date.now() }, + } +} + const resolvePrompt = (input: PromptInput.Prompt) => Prompt.make({ text: input.text, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 1174b65f40..9ad9ddc81a 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -129,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}` + if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}` return "" } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a23ecb224c..3658e67c0f 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -8,21 +8,25 @@ export type MemoryState = { } export interface Adapter { - readonly getCurrentAssistant: () => Effect.Effect - readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect - readonly getCurrentShell: (callID: string) => Effect.Effect - readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect - readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect - readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect + readonly getCurrentAssistant: () => Effect.Effect + readonly getAssistant: ( + messageID: SessionMessage.ID, + ) => Effect.Effect + readonly getShell: ( + shellID: SessionMessage.Shell["shell"]["id"], + ) => Effect.Effect + readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect + readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect } export function memory(state: MemoryState): Adapter { const assistantIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) + const shellIndex = (messageID: SessionMessage.ID) => + state.messages.findLastIndex((message) => message.id === messageID) // 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) return { getCurrentAssistant() { @@ -41,12 +45,11 @@ export function memory(state: MemoryState): Adapter { return assistant?.type === "assistant" ? assistant : undefined }) }, - getCurrentShell(callID) { + getShell(shellID) { return Effect.sync(() => { - const index = activeShellIndex(callID) - if (index < 0) return - const shell = state.messages[index] - return shell?.type === "shell" ? shell : undefined + return state.messages.find((message): message is SessionMessage.Shell => { + return message.type === "shell" && message.shell.id === shellID + }) }) }, updateAssistant(assistant) { @@ -60,7 +63,7 @@ export function memory(state: MemoryState): Adapter { }, updateShell(shell) { return Effect.sync(() => { - const index = activeShellIndex(shell.callID) + const index = shellIndex(shell.id) if (index < 0) return const current = state.messages[index] if (current?.type !== "shell") return @@ -100,7 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { - "agent.selected": (event) => { + "session.agent.selected": (event) => { return adapter.appendMessage( SessionMessage.AgentSelected.make({ id: SessionMessage.ID.fromEvent(event.id), @@ -111,7 +114,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, - "model.selected": (event) => { + "session.model.selected": (event) => { return adapter.appendMessage( SessionMessage.ModelSelected.make({ id: SessionMessage.ID.fromEvent(event.id), @@ -123,11 +126,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.moved": () => Effect.void, - renamed: () => Effect.void, - forked: () => Effect.void, - "prompt.promoted": () => Effect.void, - "prompt.admitted": () => Effect.void, - "execution.settled": () => Effect.void, + "session.renamed": () => Effect.void, + "session.forked": () => Effect.void, + "session.prompt.promoted": () => Effect.void, + "session.prompt.admitted": () => Effect.void, + "session.execution.settled": () => Effect.void, "session.context.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ @@ -137,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { time: { created: event.created }, }), ), - synthetic: (event) => { + "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, @@ -150,7 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, - "skill.activated": (event) => { + "session.skill.activated": (event) => { return adapter.appendMessage( SessionMessage.Skill.make({ id: SessionMessage.ID.fromEvent(event.id), @@ -161,25 +164,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, - "shell.started": (event) => { + "session.shell.started": (event) => { return adapter.appendMessage( SessionMessage.Shell.make({ id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, - callID: event.data.callID, - command: event.data.command, - output: "", + shell: event.data.shell, time: { created: event.created }, }), ) }, - "shell.ended": (event) => { + "session.shell.ended": (event) => { return Effect.gen(function* () { - const currentShell = yield* adapter.getCurrentShell(event.data.callID) + const currentShell = yield* adapter.getShell(event.data.shell.id) if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { + draft.shell = castDraft(event.data.shell) draft.output = event.data.output draft.time.completed = event.created }), @@ -187,7 +189,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "step.started": (event) => { + "session.step.started": (event) => { return Effect.gen(function* () { const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { @@ -210,7 +212,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }) }, - "step.ended": (event) => { + "session.step.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.created draft.finish = event.data.finish @@ -224,33 +226,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "step.failed": (event) => { + "session.step.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.created draft.finish = "error" draft.error = event.data.error }) }, - "text.started": (event) => { + "session.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, - "text.delta": (event) => { + "session.text.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text += event.data.delta }) }, - "text.ended": (event) => { + "session.text.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text = event.data.text }) }, - "tool.input.started": (event) => { + "session.tool.input.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -265,14 +267,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }) }, - "tool.input.delta": () => Effect.void, - "tool.input.ended": (event) => { + "session.tool.input.delta": () => Effect.void, + "session.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 }) }, - "tool.called": (event) => { + "session.tool.called": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { @@ -289,7 +291,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "tool.progress": (event) => { + "session.tool.progress": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -298,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "tool.success": (event) => { + "session.tool.success": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -321,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "tool.failed": (event) => { + "session.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")) { @@ -344,7 +346,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "reasoning.started": (event) => { + "session.reasoning.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -359,13 +361,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }) }, - "reasoning.delta": (event) => { + "session.reasoning.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text += event.data.delta }) }, - "reasoning.ended": (event) => { + "session.reasoning.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) { @@ -375,10 +377,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - retried: () => Effect.void, - "compaction.started": () => Effect.void, - "compaction.delta": () => Effect.void, - "compaction.ended": (event) => { + "session.retried": () => Effect.void, + "session.compaction.started": () => Effect.void, + "session.compaction.delta": () => Effect.void, + "session.compaction.ended": (event) => { return adapter.appendMessage( SessionMessage.Compaction.make({ id: SessionMessage.ID.fromEvent(event.id), @@ -391,9 +393,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, - "revert.staged": () => Effect.void, - "revert.cleared": () => Effect.void, - "revert.committed": () => Effect.void, + "session.revert.staged": () => Effect.void, + "session.revert.cleared": () => Effect.void, + "session.revert.committed": () => Effect.void, }) }) } diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index c5b552e8b4..746e448a3b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -393,18 +393,25 @@ function run(db: DatabaseService, event: MessageEvent) { return message.type === "assistant" ? message : undefined }) }, - getCurrentShell(callID) { + getShell(shellID) { return Effect.gen(function* () { - const rows = yield* db + const row = yield* db .select() .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"))) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.type, "shell"), + sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`, + ), + ) .orderBy(desc(SessionMessageTable.seq)) - .all() + .limit(1) + .get() .pipe(Effect.orDie) - return rows - .map(decodeRow) - .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + if (!row) return + const message = decodeRow(row) + return message.type === "shell" ? message : undefined }) }, updateAssistant: updateMessage, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 0d9055e480..e6a91a9ef1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -139,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] Message.make({ id: message.id, role: "user", - content: `Shell command: ${message.command}\n\n${message.output}`, + content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`, metadata: message.metadata, }), ] diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 0100ca4ae9..d18c2c8e81 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -213,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: "forked", + type: "session.forked", durable: { seq: 0 }, data: { sessionID: forked.id, parentID: parent.id }, }) @@ -378,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: "prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "prompt.promoted" }, + { durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "session.prompt.promoted" }, ]) }), ) @@ -494,8 +494,9 @@ describe("SessionV2.create", () => { 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).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } }) + expect(shell?.output?.output).toContain("hello") + expect(shell?.output?.truncated).toBe(false) expect(shell?.time.completed).toBeDefined() }), ), @@ -513,7 +514,8 @@ describe("SessionV2.create", () => { 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).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } }) + expect(shell?.shell.exit).not.toBe(0) expect(shell?.time.completed).toBeDefined() }), ), @@ -529,7 +531,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: "agent.selected", data: { agent: "plan" } }]) + ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }]) }), ) @@ -562,7 +564,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: "model.selected", data: { model } }]) + ).toMatchObject([{ type: "session.model.selected", data: { model } }]) }), ) diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index 8c49b94582..ed8dda7f4d 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -40,14 +40,14 @@ describe("SessionV2.log", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) - yield* session.rename({ sessionID: created.id, title: "renamed" }) + yield* session.rename({ sessionID: created.id, title: "session.renamed" }) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id }))) const watermark = (yield* events.sequences([created.id])).get(created.id) // 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(["renamed", "log.synced"]) + expect(items.map((item) => item.type)).toEqual(["session.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", "renamed"]) + expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"]) }), ) @@ -137,7 +137,7 @@ describe("SessionV2 watermarks", () => { const events = yield* EventV2.Service const first = yield* session.create({ location }) const second = yield* session.create({ location }) - yield* session.rename({ sessionID: first.id, title: "renamed" }) + yield* session.rename({ sessionID: first.id, title: "session.renamed" }) const page = yield* session.list() const sequences = yield* events.sequences([first.id, second.id]) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index c0d172088e..c17b979076 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,6 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater 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 { Shell } from "@opencode-ai/schema/shell" import { SessionContextCheckpointTable, SessionInputTable, @@ -257,15 +258,32 @@ describe("SessionProjector", () => { }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, - callID: "shell-1", - command: "pwd", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_projector"), + status: "running", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_projector.out", + metadata: {}, + time: { started: 0 }, + }), }) yield* events.publish(SessionEvent.Shell.Ended, { sessionID, - callID: "shell-1", - output: "/project", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_projector"), + status: "exited", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_projector.out", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }), + output: { output: "/project", cursor: 8, size: 8, truncated: false }, }) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", @@ -320,7 +338,8 @@ describe("SessionProjector", () => { metadata: { source: "projector-test" }, }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ - output: "/project", + shell: { command: "pwd", status: "exited", exit: 0 }, + output: { output: "/project", truncated: false }, time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 58a613a324..e7d3f5189f 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -256,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, "prompt.admitted"], - [1, "prompt.admitted"], - [2, "prompt.promoted"], - [3, "prompt.promoted"], + [0, "session.prompt.admitted"], + [1, "session.prompt.admitted"], + [2, "session.prompt.promoted"], + [3, "session.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, "prompt.admitted"]]) + ).toEqual([[1, "session.prompt.admitted"]]) }), ) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index d33b44728d..9aed484858 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" +import { Shell } from "@opencode-ai/schema/shell" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) @@ -87,9 +88,18 @@ describe("toLLMMessages", () => { SessionMessage.Shell.make({ id: id("shell"), type: "shell", - callID: "shell-1", - command: "pwd", - output: "/project", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_test"), + status: "exited", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_test.out", + exit: 0, + metadata: {}, + time: { started: 0, completed: 0 }, + }), + output: { output: "/project", cursor: 8, size: 8, truncated: false }, time: { created, completed: created }, }), SessionMessage.Compaction.make({ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 4f82baba96..35e84fcb6c 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "prompt.admitted.1", - "prompt.promoted.1", - "step.started.1", - "text.started.1", - "text.ended.1", - "step.ended.1", + "session.prompt.admitted.1", + "session.prompt.promoted.1", + "session.step.started.1", + "session.text.started.1", + "session.text.ended.1", + "session.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 5c8d7e07f4..d016b2694b 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 === "tool.success.1") + const success = published.find((event) => event.type === "session.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 === "tool.success.1") + const success = published.find((event) => event.type === "session.tool.success.1") expect(success?.data).toHaveProperty("result") }) @@ -110,8 +110,8 @@ test("binary failure emits no success event", async () => { }), ), ) - expect(published.some((event) => event.type === "tool.success.1")).toBe(false) - expect(published.some((event) => event.type === "tool.failed.1")).toBe(true) + expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false) + expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) }) test("old success event data containing result still decodes", () => { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e6f14c8e19..6856b6a7ce 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2985,7 +2985,7 @@ describe("SessionRunnerLLM", () => { { type: "user", text: "Interrupt provider" }, { type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } }, ]) - expect(yield* recordedEventTypes(sessionID)).toContain("step.failed.1") + expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1") yield* session.interrupt(sessionID) }), ) @@ -3029,8 +3029,8 @@ describe("SessionRunnerLLM", () => { }, ]) const eventTypes = yield* recordedEventTypes(sessionID) - expect(eventTypes).toContain("step.failed.1") - expect(eventTypes).not.toContain("step.ended.1") + expect(eventTypes).toContain("session.step.failed.1") + expect(eventTypes).not.toContain("session.step.ended.1") }), ) diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts index 60ded4ebd9..6874527222 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/packages/opencode/src/cli/cmd/run/noninteractive.ts @@ -158,14 +158,14 @@ export async function runNonInteractivePrompt(input: Input) { if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue const time = toMillis(event.created) - if (event.type === "prompt.promoted") { + if (event.type === "session.prompt.promoted") { if (event.data.inputID === messageID) { promoted = true continue } } if ( - event.type === "execution.settled" && + event.type === "session.execution.settled" && event.data.outcome === "interrupted" && (interrupted || permissionRejected || questionRejected || formCancelled) ) { @@ -173,7 +173,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (!promoted) continue - if (event.type === "step.started") { + if (event.type === "session.step.started") { const part: StepStartPart = { id: partID(event.id), sessionID: input.sessionID, @@ -189,11 +189,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "text.started") { + if (event.type === "session.text.started") { starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "text.ended") { + if (event.type === "session.text.ended") { const started = starts.get(event.data.textID) const part: TextPart = { id: started?.id ?? partID(event.id), @@ -207,11 +207,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "reasoning.started") { + if (event.type === "session.reasoning.started") { starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "reasoning.ended" && input.thinking) { + if (event.type === "session.reasoning.ended" && input.thinking) { const started = starts.get(event.data.reasoningID) const part: ReasoningPart = { id: started?.id ?? partID(event.id), @@ -236,7 +236,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "tool.input.started") { + if (event.type === "session.tool.input.started") { tools.set(event.data.callID, { id: partID(event.id), timestamp: time, @@ -246,12 +246,12 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "tool.input.ended") { + if (event.type === "session.tool.input.ended") { const current = tools.get(event.data.callID) if (current) current.raw = event.data.text continue } - if (event.type === "tool.called") { + if (event.type === "session.tool.called") { const current = tools.get(event.data.callID) tools.set(event.data.callID, { id: current?.id ?? partID(event.id), @@ -264,7 +264,7 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "tool.success") { + if (event.type === "session.tool.success") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const part: ToolPart = { id: current.id, @@ -297,7 +297,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("tool_use", time, { part })) await input.renderTool(part) continue } - if (event.type === "tool.failed") { + if (event.type === "session.tool.failed") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const error = event.data.error.message const part: ToolPart = { @@ -328,7 +328,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "step.ended") { + if (event.type === "session.step.ended") { const part: StepFinishPart = { id: partID(event.id), sessionID: input.sessionID, @@ -342,14 +342,14 @@ export async function runNonInteractivePrompt(input: Input) { emit("step_finish", time, { part }) continue } - if (event.type === "step.failed") { + if (event.type === "session.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 === "execution.settled") { + if (event.type === "session.execution.settled") { if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 9450f6cf78..ffce45bc94 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -62,7 +62,7 @@ type SessionCommit = StreamCommit // - sent: part ID → byte offset of last flushed text (for incremental output) // - visible: part ID → rendered text for an active part after display transforms // - end: part IDs whose time.end has arrived (part is finished) -// - shell: shell call ID → chosen transcript source for direct shell calls +// - shell: shell ID → chosen transcript source for direct shell calls // - echo: message ID → bash outputs to strip from the next assistant chunk type ShellCall = { source: "shell" | "tool" @@ -607,12 +607,12 @@ function toolCommit( } } -function shellPartID(callID: string): string { - return `shell:${callID}` +function shellPartID(shellID: string): string { + return `shell:${shellID}` } -function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall { - const current = data.shell.get(callID) +function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall { + const current = data.shell.get(shellID) if (current) { if (command && !current.command) { current.command = command @@ -625,7 +625,7 @@ function claimShell(data: SessionData, callID: string, source: ShellCall["source source, ...(command ? { command } : {}), } satisfies ShellCall - data.shell.set(callID, next) + data.shell.set(shellID, next) return next } @@ -728,37 +728,37 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { const data = input.data const event = input.event - if (event.type === "shell.started") { + if (event.type === "session.shell.started") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } - const shell = claimShell(data, event.properties.callID, "shell", event.properties.command) + const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command) if (shell.source !== "shell") { return out(data, commits) } - const partID = shellPartID(event.properties.callID) + const partID = shellPartID(event.properties.shell.id) if (data.ids.has(partID) || data.tools.has(partID)) { return out(data, commits, patch({ status: "running shell" })) } data.tools.add(partID) - commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command)) + commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command)) return out(data, commits, patch({ status: "running shell" })) } - if (event.type === "shell.ended") { + if (event.type === "session.shell.ended") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } - const shell = claimShell(data, event.properties.callID, "shell") + const shell = claimShell(data, event.properties.shell.id, "shell") if (shell.source !== "shell") { return out(data, commits) } - const partID = shellPartID(event.properties.callID) + const partID = shellPartID(event.properties.shell.id) const seen = data.tools.has(partID) const command = shell.command ?? "" data.tools.delete(partID) @@ -767,11 +767,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { } if (!seen && command) { - commits.push(startShell(event.properties.callID, command)) + commits.push(startShell(event.properties.shell.id, command)) } data.ids.add(partID) - commits.push(doneShell(event.properties.callID, command, event.properties.output)) + commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output)) 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 ac652423a1..1e7b039fd8 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 === "prompt.promoted") { + if (event.type === "session.prompt.promoted") { if (userFrame(child, event.data.inputID, "")) { touch(child, event.created) notifyDetail(child) } return } - if (event.type === "step.started") { + if (event.type === "session.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 === "text.delta") { + if (event.type === "session.text.delta") { const projected = child.projectedText.get(event.data.textID) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { @@ -459,7 +459,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "text.ended") { + if (event.type === "session.text.ended") { child.text.set(event.data.textID, event.data.text) child.projectedText.delete(event.data.textID) setFrame(child, `text:${event.data.textID}`, { @@ -474,7 +474,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "reasoning.delta") { + if (event.type === "session.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 === "reasoning.ended") { + if (event.type === "session.reasoning.ended") { child.reasoning.set(event.data.reasoningID, event.data.text) child.projectedReasoning.delete(event.data.reasoningID) if (!input.thinking) return @@ -510,11 +510,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "tool.input.started") { + if (event.type === "session.tool.input.started") { child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created }) return } - if (event.type === "tool.called") { + if (event.type === "session.tool.called") { const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { name: event.data.tool, @@ -537,10 +537,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "tool.success" || event.type === "tool.failed") { + if (event.type === "session.tool.success" || event.type === "session.tool.failed") { if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) - const failed = event.type === "tool.failed" + const failed = event.type === "session.tool.failed" childTool( child, { @@ -577,7 +577,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "step.failed") { + if (event.type === "session.step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", source: "system", @@ -589,7 +589,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "execution.settled") { + if (event.type === "session.execution.settled") { child.status = event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" touch(child, event.created) @@ -613,15 +613,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return { main(event) { - if (event.type === "tool.called") { + if (event.type === "session.tool.called") { if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) return } - if (event.type === "tool.failed") { + if (event.type === "session.tool.failed") { pendingCalls.delete(event.data.callID) return } - if (event.type !== "tool.success") return + if (event.type !== "session.tool.success") return const pending = pendingCalls.get(event.data.callID) pendingCalls.delete(event.data.callID) const found = childSessionID(record(event.data.structured)) 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 c15ed48e5b..42e2c7bd53 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -213,7 +213,9 @@ function promptAgents(next: SessionTurnInput) { ? [ { name: part.name, - source: part.source ? { start: part.source.start, end: part.source.end, text: part.source.value } : undefined, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, }, ] : [], @@ -404,27 +406,39 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -604,7 +636,7 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -645,7 +677,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 839b99656b..4462c53a97 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -25,7 +25,7 @@ function prompted(inputID: string): V2Event { return { id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: { aggregateID: "ses_1", seq: 0, version: 1 }, data: { sessionID: "ses_1", inputID }, } @@ -35,7 +35,7 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event { return { id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome }, } } diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 89a6751d1b..805bcd486f 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: "shell.started", + type: "session.shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -353,7 +353,7 @@ describe("run session data", () => { data = started.data const ended = reduce(data, { - type: "shell.ended", + type: "session.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: "shell.started", + type: "session.shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -409,7 +409,7 @@ describe("run session data", () => { ).toEqual([]) data = reduce(data, { - type: "shell.ended", + type: "session.shell.ended", properties: { sessionID: "session-1", timestamp: 2, @@ -463,7 +463,7 @@ describe("run session data", () => { expect( reduce(data, { - type: "shell.started", + type: "session.shell.started", properties: { sessionID: "session-1", timestamp: 1, @@ -497,7 +497,7 @@ describe("run session data", () => { expect( reduce(data, { - type: "shell.ended", + type: "session.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 e43e88f934..cd2d131642 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -200,7 +200,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -210,7 +210,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_text", created: 0, - type: "text.delta", + type: "session.text.delta", data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", @@ -221,7 +221,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -259,7 +259,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -269,7 +269,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) }) @@ -353,7 +353,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -363,7 +363,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) }) @@ -450,7 +450,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -460,7 +460,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) }) @@ -724,7 +724,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_text", created: 0, - type: "text.delta", + type: "session.text.delta", data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", @@ -809,7 +809,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_reasoning", created: 0, - type: "reasoning.ended", + type: "session.reasoning.ended", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -865,7 +865,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -921,7 +921,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -931,7 +931,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -981,7 +981,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -993,7 +993,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -1021,16 +1021,42 @@ describe("V2 mini transport", () => { events.push({ id: "evt_shell_start", created: 0, - type: "shell.started", + type: "session.shell.started", durable: durable("ses_1"), - data: { sessionID: "ses_1", callID: "call_shell", command: "ls" }, + data: { + sessionID: "ses_1", + shell: { + id: "sh_shell", + status: "running", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + metadata: {}, + time: { started: 0 }, + }, + }, }) events.push({ id: "evt_shell_end", created: 0, - type: "shell.ended", + type: "session.shell.ended", durable: durable("ses_1", 1), - data: { sessionID: "ses_1", callID: "call_shell", output: "file.txt" }, + data: { + sessionID: "ses_1", + shell: { + id: "sh_shell", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, + }, }) }) return ok(undefined) as never @@ -1047,8 +1073,8 @@ describe("V2 mini transport", () => { expect(request).toMatchObject({ sessionID: "ses_1", command: "ls" }) expect(ui.commits.filter((item) => item.shell)).toMatchObject([ - { phase: "start", tool: "bash", toolState: "running", shell: { callID: "call_shell", command: "ls" } }, - { phase: "progress", text: "file.txt", toolState: "completed", shell: { callID: "call_shell", command: "ls" } }, + { phase: "start", tool: "bash", toolState: "running", shell: { callID: "sh_shell", command: "ls" } }, + { phase: "progress", text: "file.txt", toolState: "completed", shell: { callID: "sh_shell", command: "ls" } }, ]) expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "running shell" } }) await transport.close() @@ -1107,9 +1133,18 @@ describe("V2 mini transport", () => { { id: "msg_shell", type: "shell" as const, - callID: "call_1", - command: "ls", - output: "file.txt", + shell: { + id: "sh_1", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, time: { created: 1, completed: 2 }, }, ], @@ -1127,15 +1162,29 @@ describe("V2 mini transport", () => { events.push({ id: "evt_shell_end", created: 0, - type: "shell.ended", + type: "session.shell.ended", durable: durable("ses_1", 1), - data: { sessionID: "ses_1", callID: "call_1", output: "file.txt" }, + data: { + sessionID: "ses_1", + shell: { + id: "sh_1", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, + }, }) await Bun.sleep(0) await Bun.sleep(0) expect(ui.commits.filter((item) => item.shell)).toMatchObject([ - { phase: "start", shell: { callID: "call_1", command: "ls" } }, + { phase: "start", shell: { callID: "sh_1", command: "ls" } }, { phase: "progress", text: "file.txt", toolState: "completed" }, ]) await transport.close() @@ -1160,7 +1209,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1170,7 +1219,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) }) @@ -1236,7 +1285,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill", created: 0, - type: "skill.activated", + type: "session.skill.activated", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1247,7 +1296,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) }) @@ -1317,7 +1366,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_unrelated_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await Bun.sleep(0) @@ -1327,7 +1376,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill", created: 0, - type: "skill.activated", + type: "session.skill.activated", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1338,7 +1387,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -1376,7 +1425,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill", created: 0, - type: "skill.activated", + type: "session.skill.activated", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1438,7 +1487,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_step", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("ses_child"), data: { sessionID: "ses_child", @@ -1456,7 +1505,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_text", created: 0, - type: "text.delta", + type: "session.text.delta", data: { sessionID: "ses_child", assistantMessageID: "msg_child_a", @@ -1470,7 +1519,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_child", outcome: "success" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) @@ -1515,7 +1564,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_step", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("ses_child"), data: { sessionID: "ses_child", @@ -1527,7 +1576,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "ses_child", outcome: "interrupted" }, }) await Bun.sleep(0) @@ -1574,7 +1623,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_step", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("ses_child"), data: { sessionID: "ses_child", @@ -1587,7 +1636,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_parent_call", created: 0, - type: "tool.called", + type: "session.tool.called", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1601,7 +1650,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_parent_success", created: 0, - type: "tool.success", + type: "session.tool.success", durable: durable("ses_1", 1), data: { sessionID: "ses_1", @@ -1616,7 +1665,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "execution.settled", + type: "session.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/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 3eec0c9682..ac18a8a2cf 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -99,10 +99,10 @@ describe("pty HttpApi bridge", () => { const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }), + body: JSON.stringify({ title: "session.renamed", size: { cols: 80, rows: 24 } }), }) expect(updated.status).toBe(200) - expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" }) + expect(await updated.json()).toMatchObject({ id: info.id, title: "session.renamed" }) } finally { await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers }) } diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index c81b3b771b..0af92ae382 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -573,7 +573,7 @@ describe("HttpApi SDK", () => { const child = yield* capture(() => sdk.session.create({ title: "child", parentID })) const childID = String(record(child.data).id) const get = yield* capture(() => sdk.session.get({ sessionID: parentID })) - const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" })) + const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "session.renamed" })) const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 })) const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index e394d8084f..57febfa06e 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -271,7 +271,7 @@ describe("tool.apply_patch freeform", () => { yield* execute({ patchText }, ctx) - const moved = path.join(test.directory, "renamed", "dir", "name.txt") + const moved = path.join(test.directory, "session.renamed", "dir", "name.txt") yield* expectReadFailure(original) expect(yield* readText(moved)).toBe("new content\n") }), @@ -282,7 +282,7 @@ describe("tool.apply_patch freeform", () => { const test = yield* TestInstance const { ctx } = makeCtx() const original = path.join(test.directory, "old", "name.txt") - const destination = path.join(test.directory, "renamed", "dir", "name.txt") + const destination = path.join(test.directory, "session.renamed", "dir", "name.txt") yield* makeDir(path.dirname(original)) yield* makeDir(path.dirname(destination)) yield* writeText(original, "from\n") diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 6302a951dd..9432ee6635 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -79593,8 +79593,8 @@ } } }, - "synthetic": { - "id": "synthetic", + "session.synthetic": { + "id": "session.synthetic", "env": ["SYNTHETIC_API_KEY"], "npm": "@ai-sdk/openai-compatible", "api": "https://api.synthetic.new/openai/v1", diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 4da4dc9d13..1fc3dda0df 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -22,7 +22,7 @@ test.skip("step snapshots carry over to assistant messages", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "step.started", + type: "session.step.started", durable: durable(sessionID), data: { sessionID, @@ -44,7 +44,7 @@ test.skip("step snapshots carry over to assistant messages", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "step.ended", + type: "session.step.ended", durable: durable(sessionID, 1, 2), data: { sessionID, @@ -77,7 +77,7 @@ test.skip("text ended populates assistant text content", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "step.started", + type: "session.step.started", durable: durable(sessionID), data: { sessionID, @@ -96,7 +96,7 @@ test.skip("text ended populates assistant text content", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "text.started", + type: "session.text.started", durable: durable(sessionID, 1), data: { sessionID, @@ -110,7 +110,7 @@ test.skip("text ended populates assistant text content", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "text.ended", + type: "session.text.ended", durable: durable(sessionID, 2), data: { sessionID, @@ -136,7 +136,7 @@ test.skip("tool completion stores completed timestamp", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "step.started", + type: "session.step.started", durable: durable(sessionID), data: { sessionID, @@ -155,7 +155,7 @@ test.skip("tool completion stores completed timestamp", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "tool.input.started", + type: "session.tool.input.started", durable: durable(sessionID, 1), data: { sessionID, @@ -170,7 +170,7 @@ test.skip("tool completion stores completed timestamp", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "tool.called", + type: "session.tool.called", durable: durable(sessionID, 2), data: { sessionID, @@ -187,7 +187,7 @@ test.skip("tool completion stores completed timestamp", () => { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "tool.success", + type: "session.tool.success", durable: durable(sessionID, 3), data: { sessionID, @@ -218,7 +218,7 @@ test("compaction events reduce to compaction message only when completed", () => SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id, created: DateTime.makeUnsafe(0), - type: "compaction.started", + type: "session.compaction.started", durable: durable(sessionID), data: { sessionID, @@ -233,7 +233,7 @@ test("compaction events reduce to compaction message only when completed", () => SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "compaction.delta", + type: "session.compaction.delta", data: { sessionID, text: "hello ", @@ -245,7 +245,7 @@ test("compaction events reduce to compaction message only when completed", () => SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), created: DateTime.makeUnsafe(0), - type: "compaction.delta", + type: "session.compaction.delta", data: { sessionID, text: "summary", @@ -257,7 +257,7 @@ test("compaction events reduce to compaction message only when completed", () => SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: endedID, created: DateTime.makeUnsafe(0), - type: "compaction.ended", + type: "session.compaction.ended", durable: durable(sessionID, 1), data: { sessionID, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index fce091545a..824b1690db 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -12,6 +12,7 @@ import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" import { Revert } from "./revert.js" +import { Shell as ShellSchema } from "./shell.js" export { FileAttachment } @@ -51,7 +52,7 @@ export const UnknownError = SessionMessage.UnknownError export type UnknownError = SessionMessage.UnknownError export const AgentSelected = Event.durable({ - type: "agent.selected", + type: "session.agent.selected", ...options, schema: { ...Base, @@ -61,7 +62,7 @@ export const AgentSelected = Event.durable({ export type AgentSelected = typeof AgentSelected.Type export const ModelSelected = Event.durable({ - type: "model.selected", + type: "session.model.selected", ...options, schema: { ...Base, @@ -82,7 +83,7 @@ export const Moved = Event.durable({ export type Moved = typeof Moved.Type export const Renamed = Event.durable({ - type: "renamed", + type: "session.renamed", ...options, schema: { ...Base, @@ -92,7 +93,7 @@ export const Renamed = Event.durable({ export type Renamed = typeof Renamed.Type export const Forked = Event.durable({ - type: "forked", + type: "session.forked", ...options, schema: { ...Base, @@ -103,7 +104,7 @@ export const Forked = Event.durable({ export type Forked = typeof Forked.Type export const PromptPromoted = Event.durable({ - type: "prompt.promoted", + type: "session.prompt.promoted", ...options, schema: { sessionID: SessionID, @@ -113,14 +114,14 @@ export const PromptPromoted = Event.durable({ export type PromptPromoted = typeof PromptPromoted.Type export const PromptAdmitted = Event.durable({ - type: "prompt.admitted", + type: "session.prompt.admitted", ...options, schema: PromptFields, }) export type PromptAdmitted = typeof PromptAdmitted.Type export const ExecutionSettled = Event.ephemeral({ - type: "execution.settled", + type: "session.execution.settled", schema: { ...Base, outcome: Schema.Literals(["success", "failure", "interrupted"]), @@ -140,7 +141,7 @@ export const ContextUpdated = Event.durable({ export type ContextUpdated = typeof ContextUpdated.Type export const Synthetic = Event.durable({ - type: "synthetic", + type: "session.synthetic", ...options, schema: { ...Base, @@ -153,7 +154,7 @@ export type Synthetic = typeof Synthetic.Type export namespace Skill { export const Activated = Event.durable({ - type: "skill.activated", + type: "session.skill.activated", ...options, schema: { ...Base, @@ -166,23 +167,22 @@ export namespace Skill { export namespace Shell { export const Started = Event.durable({ - type: "shell.started", + type: "session.shell.started", ...options, schema: { ...Base, - callID: Schema.String, - command: Schema.String, + shell: ShellSchema.Info, }, }) export type Started = typeof Started.Type export const Ended = Event.durable({ - type: "shell.ended", + type: "session.shell.ended", ...options, schema: { ...Base, - callID: Schema.String, - output: Schema.String, + shell: ShellSchema.Info, + output: ShellSchema.Output, }, }) export type Ended = typeof Ended.Type @@ -190,7 +190,7 @@ export namespace Shell { export namespace Step { export const Started = Event.durable({ - type: "step.started", + type: "session.step.started", ...options, schema: { ...Base, @@ -203,7 +203,7 @@ export namespace Step { export type Started = typeof Started.Type export const Ended = Event.durable({ - type: "step.ended", + type: "session.step.ended", ...stepSettlementOptions, schema: { ...Base, @@ -226,7 +226,7 @@ export namespace Step { export type Ended = typeof Ended.Type export const Failed = Event.durable({ - type: "step.failed", + type: "session.step.failed", ...stepSettlementOptions, schema: { ...Base, @@ -239,7 +239,7 @@ export namespace Step { export namespace Text { export const Started = Event.durable({ - type: "text.started", + type: "session.text.started", ...options, schema: { ...Base, @@ -251,7 +251,7 @@ export namespace Text { // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. export const Delta = Event.ephemeral({ - type: "text.delta", + type: "session.text.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -262,7 +262,7 @@ export namespace Text { export type Delta = typeof Delta.Type export const Ended = Event.durable({ - type: "text.ended", + type: "session.text.ended", ...options, schema: { ...Base, @@ -276,7 +276,7 @@ export namespace Text { export namespace Reasoning { export const Started = Event.durable({ - type: "reasoning.started", + type: "session.reasoning.started", ...options, schema: { ...Base, @@ -289,7 +289,7 @@ export namespace Reasoning { // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. export const Delta = Event.ephemeral({ - type: "reasoning.delta", + type: "session.reasoning.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -300,7 +300,7 @@ export namespace Reasoning { export type Delta = typeof Delta.Type export const Ended = Event.durable({ - type: "reasoning.ended", + type: "session.reasoning.ended", ...options, schema: { ...Base, @@ -322,7 +322,7 @@ export namespace Tool { export namespace Input { export const Started = Event.durable({ - type: "tool.input.started", + type: "session.tool.input.started", ...options, schema: { ...ToolBase, @@ -333,7 +333,7 @@ export namespace Tool { // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. export const Delta = Event.ephemeral({ - type: "tool.input.delta", + type: "session.tool.input.delta", schema: { ...ToolBase, delta: Schema.String, @@ -342,7 +342,7 @@ export namespace Tool { export type Delta = typeof Delta.Type export const Ended = Event.durable({ - type: "tool.input.ended", + type: "session.tool.input.ended", ...options, schema: { ...ToolBase, @@ -353,7 +353,7 @@ export namespace Tool { } export const Called = Event.durable({ - type: "tool.called", + type: "session.tool.called", ...options, schema: { ...ToolBase, @@ -372,7 +372,7 @@ export namespace Tool { * transitions or at a bounded cadence, not persist every stdout/stderr chunk. */ export const Progress = Event.durable({ - type: "tool.progress", + type: "session.tool.progress", ...options, schema: { ...ToolBase, @@ -383,7 +383,7 @@ export namespace Tool { export type Progress = typeof Progress.Type export const Success = Event.durable({ - type: "tool.success", + type: "session.tool.success", ...options, schema: { ...ToolBase, @@ -400,7 +400,7 @@ export namespace Tool { export type Success = typeof Success.Type export const Failed = Event.durable({ - type: "tool.failed", + type: "session.tool.failed", ...options, schema: { ...ToolBase, @@ -428,7 +428,7 @@ export const RetryError = Schema.Struct({ export interface RetryError extends Schema.Schema.Type {} export const Retried = Event.durable({ - type: "retried", + type: "session.retried", ...options, schema: { ...Base, @@ -440,7 +440,7 @@ export type Retried = typeof Retried.Type export namespace Compaction { export const Started = Event.durable({ - type: "compaction.started", + type: "session.compaction.started", ...options, schema: { ...Base, @@ -450,7 +450,7 @@ export namespace Compaction { export type Started = typeof Started.Type export const Delta = Event.ephemeral({ - type: "compaction.delta", + type: "session.compaction.delta", schema: { ...Base, text: Schema.String, @@ -459,7 +459,7 @@ export namespace Compaction { export type Delta = typeof Delta.Type export const Ended = Event.durable({ - type: "compaction.ended", + type: "session.compaction.ended", ...options, schema: { ...Base, @@ -473,13 +473,13 @@ export namespace Compaction { export namespace RevertEvent { export const Staged = Event.durable({ - type: "revert.staged", + type: "session.revert.staged", ...options, schema: { ...Base, revert: Revert.State }, }) - export const Cleared = Event.durable({ type: "revert.cleared", ...options, schema: Base }) + export const Cleared = Event.durable({ type: "session.revert.cleared", ...options, schema: Base }) export const Committed = Event.durable({ - type: "revert.committed", + type: "session.revert.committed", ...options, schema: { ...Base, messageID: SessionMessage.ID }, }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 349d04d06e..e3d81a31d7 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -9,6 +9,7 @@ import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" import { Event } from "./event.js" +import { Shell as ShellSchema } from "./shell.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), @@ -82,9 +83,8 @@ export interface Shell extends Schema.Schema.Type {} export const Shell = Schema.Struct({ ...Base, type: Schema.Literal("shell"), - callID: Schema.String, - command: Schema.String, - output: Schema.String, + shell: ShellSchema.Info, + output: ShellSchema.Output.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 4fa23e75f7..873e415c61 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -53,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("step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("session.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) @@ -74,8 +74,8 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Durable.get("step.ended.1")).toBe(SessionEvent.Step.Ended) - expect(EventManifest.Durable.has("step.ended.2")).toBe(false) + expect(EventManifest.Durable.get("session.step.ended.1")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false) }) test("derives durable definitions from explicit definition durability", () => { @@ -88,37 +88,37 @@ describe("public event manifest", () => { "message.removed.1", "message.part.updated.1", "message.part.removed.1", - "agent.selected.1", - "model.selected.1", + "session.agent.selected.1", + "session.model.selected.1", "session.moved.1", - "renamed.1", - "forked.1", - "prompt.promoted.1", - "prompt.admitted.1", + "session.renamed.1", + "session.forked.1", + "session.prompt.promoted.1", + "session.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", + "session.synthetic.1", + "session.skill.activated.1", + "session.shell.started.1", + "session.shell.ended.1", + "session.step.started.1", + "session.step.ended.1", + "session.step.failed.1", + "session.text.started.1", + "session.text.ended.1", + "session.tool.input.started.1", + "session.tool.input.ended.1", + "session.tool.called.1", + "session.tool.progress.1", + "session.tool.success.1", + "session.tool.failed.1", + "session.reasoning.started.1", + "session.reasoning.ended.1", + "session.retried.1", + "session.compaction.started.1", + "session.compaction.ended.1", + "session.revert.staged.1", + "session.revert.cleared.1", + "session.revert.committed.1", ].toSorted(), ) expect(SessionEvent.DurableDefinitions).toEqual( diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 5419b7e838..4ac31b70ab 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 === "prompt.promoted" && event.data.inputID === wake.id), + Stream.filter((event) => event.type === "session.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("prompt.promoted") + expect(prompted.type).toBe("session.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: "model.selected", durable: { seq: 1 } }) + expect(event).toMatchObject({ type: "session.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 === "prompt.promoted" && event.data.sessionID === id + : event.type === "session.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 === "agent.selected" && notification.data.sessionID === id + : notification.type === "session.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 d87f8bf98a..220bf43da3 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -60,7 +60,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { 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( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( name, ) && !reachable.has(name) @@ -100,7 +100,7 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() 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( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( generatedTypes, ) ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 56cdccf087..27ca0f00cd 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 - | EventAgentSelected - | EventModelSelected + | EventSessionAgentSelected + | EventSessionModelSelected | EventSessionMoved - | EventRenamed - | EventForked - | EventPromptPromoted - | EventPromptAdmitted - | EventExecutionSettled + | EventSessionRenamed + | EventSessionForked + | EventSessionPromptPromoted + | EventSessionPromptAdmitted + | EventSessionExecutionSettled | 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 + | EventSessionSynthetic + | EventSessionSkillActivated + | EventSessionShellStarted + | EventSessionShellEnded + | EventSessionStepStarted + | EventSessionStepEnded + | EventSessionStepFailed + | EventSessionTextStarted + | EventSessionTextDelta + | EventSessionTextEnded + | EventSessionReasoningStarted + | EventSessionReasoningDelta + | EventSessionReasoningEnded + | EventSessionToolInputStarted + | EventSessionToolInputDelta + | EventSessionToolInputEnded + | EventSessionToolCalled + | EventSessionToolProgress + | EventSessionToolSuccess + | EventSessionToolFailed + | EventSessionRetried + | EventSessionCompactionStarted + | EventSessionCompactionDelta + | EventSessionCompactionEnded + | EventSessionRevertStaged + | EventSessionRevertCleared + | EventSessionRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError @@ -657,17 +657,6 @@ export type Prompt = { agents?: Array } -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number -} - export type Shell = { id: string status: "running" | "exited" | "timeout" | "killed" @@ -686,6 +675,17 @@ export type Shell = { } } +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + export type Todo = { /** * Brief description of the task @@ -858,7 +858,7 @@ export type GlobalEvent = { } | { id: string - type: "agent.selected" + type: "session.agent.selected" properties: { sessionID: string agent: string @@ -866,7 +866,7 @@ export type GlobalEvent = { } | { id: string - type: "model.selected" + type: "session.model.selected" properties: { sessionID: string model: ModelRef @@ -883,7 +883,7 @@ export type GlobalEvent = { } | { id: string - type: "renamed" + type: "session.renamed" properties: { sessionID: string title: string @@ -891,7 +891,7 @@ export type GlobalEvent = { } | { id: string - type: "forked" + type: "session.forked" properties: { sessionID: string parentID: string @@ -900,7 +900,7 @@ export type GlobalEvent = { } | { id: string - type: "prompt.promoted" + type: "session.prompt.promoted" properties: { sessionID: string inputID: string @@ -908,7 +908,7 @@ export type GlobalEvent = { } | { id: string - type: "prompt.admitted" + type: "session.prompt.admitted" properties: { sessionID: string inputID: string @@ -918,7 +918,7 @@ export type GlobalEvent = { } | { id: string - type: "execution.settled" + type: "session.execution.settled" properties: { sessionID: string outcome: "success" | "failure" | "interrupted" @@ -935,7 +935,7 @@ export type GlobalEvent = { } | { id: string - type: "synthetic" + type: "session.synthetic" properties: { sessionID: string text: string @@ -947,7 +947,7 @@ export type GlobalEvent = { } | { id: string - type: "skill.activated" + type: "session.skill.activated" properties: { sessionID: string name: string @@ -956,25 +956,29 @@ export type GlobalEvent = { } | { id: string - type: "shell.started" + type: "session.shell.started" properties: { sessionID: string - callID: string - command: string + shell: Shell } } | { id: string - type: "shell.ended" + type: "session.shell.ended" properties: { sessionID: string - callID: string - output: string + shell: Shell + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } | { id: string - type: "step.started" + type: "session.step.started" properties: { sessionID: string assistantMessageID: string @@ -985,7 +989,7 @@ export type GlobalEvent = { } | { id: string - type: "step.ended" + type: "session.step.ended" properties: { sessionID: string assistantMessageID: string @@ -1006,7 +1010,7 @@ export type GlobalEvent = { } | { id: string - type: "step.failed" + type: "session.step.failed" properties: { sessionID: string assistantMessageID: string @@ -1015,7 +1019,7 @@ export type GlobalEvent = { } | { id: string - type: "text.started" + type: "session.text.started" properties: { sessionID: string assistantMessageID: string @@ -1024,7 +1028,7 @@ export type GlobalEvent = { } | { id: string - type: "text.delta" + type: "session.text.delta" properties: { sessionID: string assistantMessageID: string @@ -1034,7 +1038,7 @@ export type GlobalEvent = { } | { id: string - type: "text.ended" + type: "session.text.ended" properties: { sessionID: string assistantMessageID: string @@ -1044,7 +1048,7 @@ export type GlobalEvent = { } | { id: string - type: "reasoning.started" + type: "session.reasoning.started" properties: { sessionID: string assistantMessageID: string @@ -1054,7 +1058,7 @@ export type GlobalEvent = { } | { id: string - type: "reasoning.delta" + type: "session.reasoning.delta" properties: { sessionID: string assistantMessageID: string @@ -1064,7 +1068,7 @@ export type GlobalEvent = { } | { id: string - type: "reasoning.ended" + type: "session.reasoning.ended" properties: { sessionID: string assistantMessageID: string @@ -1075,7 +1079,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.input.started" + type: "session.tool.input.started" properties: { sessionID: string assistantMessageID: string @@ -1085,7 +1089,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.input.delta" + type: "session.tool.input.delta" properties: { sessionID: string assistantMessageID: string @@ -1095,7 +1099,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.input.ended" + type: "session.tool.input.ended" properties: { sessionID: string assistantMessageID: string @@ -1105,7 +1109,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.called" + type: "session.tool.called" properties: { sessionID: string assistantMessageID: string @@ -1122,7 +1126,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.progress" + type: "session.tool.progress" properties: { sessionID: string assistantMessageID: string @@ -1135,7 +1139,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.success" + type: "session.tool.success" properties: { sessionID: string assistantMessageID: string @@ -1154,7 +1158,7 @@ export type GlobalEvent = { } | { id: string - type: "tool.failed" + type: "session.tool.failed" properties: { sessionID: string assistantMessageID: string @@ -1169,7 +1173,7 @@ export type GlobalEvent = { } | { id: string - type: "retried" + type: "session.retried" properties: { sessionID: string attempt: number @@ -1178,7 +1182,7 @@ export type GlobalEvent = { } | { id: string - type: "compaction.started" + type: "session.compaction.started" properties: { sessionID: string reason: "auto" | "manual" @@ -1186,7 +1190,7 @@ export type GlobalEvent = { } | { id: string - type: "compaction.delta" + type: "session.compaction.delta" properties: { sessionID: string text: string @@ -1194,7 +1198,7 @@ export type GlobalEvent = { } | { id: string - type: "compaction.ended" + type: "session.compaction.ended" properties: { sessionID: string reason: "auto" | "manual" @@ -1204,7 +1208,7 @@ export type GlobalEvent = { } | { id: string - type: "revert.staged" + type: "session.revert.staged" properties: { sessionID: string revert: RevertState @@ -1212,14 +1216,14 @@ export type GlobalEvent = { } | { id: string - type: "revert.cleared" + type: "session.revert.cleared" properties: { sessionID: string } } | { id: string - type: "revert.committed" + type: "session.revert.committed" properties: { sessionID: string messageID: string @@ -1704,37 +1708,37 @@ export type GlobalEvent = { | SyncEventMessageRemoved | SyncEventMessagePartUpdated | SyncEventMessagePartRemoved - | SyncEventAgentSelected - | SyncEventModelSelected + | SyncEventSessionAgentSelected + | SyncEventSessionModelSelected | SyncEventSessionMoved - | SyncEventRenamed - | SyncEventForked - | SyncEventPromptPromoted - | SyncEventPromptAdmitted + | SyncEventSessionRenamed + | SyncEventSessionForked + | SyncEventSessionPromptPromoted + | SyncEventSessionPromptAdmitted | SyncEventSessionContextUpdated - | SyncEventSynthetic - | SyncEventSkillActivated - | SyncEventShellStarted - | SyncEventShellEnded - | SyncEventStepStarted - | SyncEventStepEnded - | SyncEventStepFailed - | SyncEventTextStarted - | SyncEventTextEnded - | SyncEventReasoningStarted - | SyncEventReasoningEnded - | SyncEventToolInputStarted - | SyncEventToolInputEnded - | SyncEventToolCalled - | SyncEventToolProgress - | SyncEventToolSuccess - | SyncEventToolFailed - | SyncEventRetried - | SyncEventCompactionStarted - | SyncEventCompactionEnded - | SyncEventRevertStaged - | SyncEventRevertCleared - | SyncEventRevertCommitted + | SyncEventSessionSynthetic + | SyncEventSessionSkillActivated + | SyncEventSessionShellStarted + | SyncEventSessionShellEnded + | SyncEventSessionStepStarted + | SyncEventSessionStepEnded + | SyncEventSessionStepFailed + | SyncEventSessionTextStarted + | SyncEventSessionTextEnded + | SyncEventSessionReasoningStarted + | SyncEventSessionReasoningEnded + | SyncEventSessionToolInputStarted + | SyncEventSessionToolInputEnded + | SyncEventSessionToolCalled + | SyncEventSessionToolProgress + | SyncEventSessionToolSuccess + | SyncEventSessionToolFailed + | SyncEventSessionRetried + | SyncEventSessionCompactionStarted + | SyncEventSessionCompactionEnded + | SyncEventSessionRevertStaged + | SyncEventSessionRevertCleared + | SyncEventSessionRevertCommitted } /** @@ -2854,120 +2858,56 @@ export type UnknownError1 = { ref?: string } -export type Renamed = { +export type Shell1 = { id: string - created: number - metadata?: { + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + 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 + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" } } export type SessionDurableEvent = - | AgentSelected - | ModelSelected + | SessionAgentSelected + | SessionModelSelected | SessionMoved - | Renamed - | Forked - | PromptPromoted - | PromptAdmitted + | SessionRenamed + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted | SessionContextUpdated - | Synthetic - | SkillActivated - | ShellStarted - | ShellEnded - | StepStarted - | StepEnded - | StepFailed - | TextStarted - | TextEnded - | ReasoningStarted - | ReasoningEnded - | ToolInputStarted - | ToolInputEnded - | ToolCalled - | ToolProgress - | ToolSuccess - | ToolFailed - | Retried - | CompactionStarted - | CompactionEnded - | RevertStaged - | RevertCleared - | RevertCommitted + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetried + | SessionCompactionStarted + | SessionCompactionEnded + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted export type SessionLogItem = SessionDurableEvent | EventLogSynced @@ -3022,24 +2962,6 @@ export type OutputFormat1 = retryCount?: number } -export type Shell1 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionStatus2 = { id: string created: number @@ -3096,42 +3018,42 @@ export type V2Event = | MessageRemoved | MessagePartUpdated | MessagePartRemoved - | AgentSelected - | ModelSelected + | SessionAgentSelected + | SessionModelSelected | SessionMoved - | Renamed - | Forked - | PromptPromoted - | PromptAdmitted - | ExecutionSettled + | SessionRenamed + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | SessionExecutionSettled | 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 + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextDelta + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningDelta + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputDelta + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetried + | SessionCompactionStarted + | SessionCompactionDelta + | SessionCompactionEnded + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted | MessagePartDelta | SessionDiff | SessionError @@ -3689,11 +3611,11 @@ export type SyncEventMessagePartRemoved = { } } -export type SyncEventAgentSelected = { +export type SyncEventSessionAgentSelected = { type: "sync" id: string syncEvent: { - type: "agent.selected.1" + type: "session.agent.selected.1" id: string seq: number aggregateID: string @@ -3704,11 +3626,11 @@ export type SyncEventAgentSelected = { } } -export type SyncEventModelSelected = { +export type SyncEventSessionModelSelected = { type: "sync" id: string syncEvent: { - type: "model.selected.1" + type: "session.model.selected.1" id: string seq: number aggregateID: string @@ -3735,11 +3657,11 @@ export type SyncEventSessionMoved = { } } -export type SyncEventRenamed = { +export type SyncEventSessionRenamed = { type: "sync" id: string syncEvent: { - type: "renamed.1" + type: "session.renamed.1" id: string seq: number aggregateID: string @@ -3750,11 +3672,11 @@ export type SyncEventRenamed = { } } -export type SyncEventForked = { +export type SyncEventSessionForked = { type: "sync" id: string syncEvent: { - type: "forked.1" + type: "session.forked.1" id: string seq: number aggregateID: string @@ -3766,11 +3688,11 @@ export type SyncEventForked = { } } -export type SyncEventPromptPromoted = { +export type SyncEventSessionPromptPromoted = { type: "sync" id: string syncEvent: { - type: "prompt.promoted.1" + type: "session.prompt.promoted.1" id: string seq: number aggregateID: string @@ -3781,11 +3703,11 @@ export type SyncEventPromptPromoted = { } } -export type SyncEventPromptAdmitted = { +export type SyncEventSessionPromptAdmitted = { type: "sync" id: string syncEvent: { - type: "prompt.admitted.1" + type: "session.prompt.admitted.1" id: string seq: number aggregateID: string @@ -3813,11 +3735,11 @@ export type SyncEventSessionContextUpdated = { } } -export type SyncEventSynthetic = { +export type SyncEventSessionSynthetic = { type: "sync" id: string syncEvent: { - type: "synthetic.1" + type: "session.synthetic.1" id: string seq: number aggregateID: string @@ -3832,11 +3754,11 @@ export type SyncEventSynthetic = { } } -export type SyncEventSkillActivated = { +export type SyncEventSessionSkillActivated = { type: "sync" id: string syncEvent: { - type: "skill.activated.1" + type: "session.skill.activated.1" id: string seq: number aggregateID: string @@ -3848,43 +3770,47 @@ export type SyncEventSkillActivated = { } } -export type SyncEventShellStarted = { +export type SyncEventSessionShellStarted = { type: "sync" id: string syncEvent: { - type: "shell.started.1" + type: "session.shell.started.1" id: string seq: number aggregateID: string data: { sessionID: string - callID: string - command: string + shell: Shell } } } -export type SyncEventShellEnded = { +export type SyncEventSessionShellEnded = { type: "sync" id: string syncEvent: { - type: "shell.ended.1" + type: "session.shell.ended.1" id: string seq: number aggregateID: string data: { sessionID: string - callID: string - output: string + shell: Shell + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } } -export type SyncEventStepStarted = { +export type SyncEventSessionStepStarted = { type: "sync" id: string syncEvent: { - type: "step.started.1" + type: "session.step.started.1" id: string seq: number aggregateID: string @@ -3898,11 +3824,11 @@ export type SyncEventStepStarted = { } } -export type SyncEventStepEnded = { +export type SyncEventSessionStepEnded = { type: "sync" id: string syncEvent: { - type: "step.ended.1" + type: "session.step.ended.1" id: string seq: number aggregateID: string @@ -3926,11 +3852,11 @@ export type SyncEventStepEnded = { } } -export type SyncEventStepFailed = { +export type SyncEventSessionStepFailed = { type: "sync" id: string syncEvent: { - type: "step.failed.1" + type: "session.step.failed.1" id: string seq: number aggregateID: string @@ -3942,11 +3868,11 @@ export type SyncEventStepFailed = { } } -export type SyncEventTextStarted = { +export type SyncEventSessionTextStarted = { type: "sync" id: string syncEvent: { - type: "text.started.1" + type: "session.text.started.1" id: string seq: number aggregateID: string @@ -3958,11 +3884,11 @@ export type SyncEventTextStarted = { } } -export type SyncEventTextEnded = { +export type SyncEventSessionTextEnded = { type: "sync" id: string syncEvent: { - type: "text.ended.1" + type: "session.text.ended.1" id: string seq: number aggregateID: string @@ -3975,11 +3901,11 @@ export type SyncEventTextEnded = { } } -export type SyncEventReasoningStarted = { +export type SyncEventSessionReasoningStarted = { type: "sync" id: string syncEvent: { - type: "reasoning.started.1" + type: "session.reasoning.started.1" id: string seq: number aggregateID: string @@ -3992,11 +3918,11 @@ export type SyncEventReasoningStarted = { } } -export type SyncEventReasoningEnded = { +export type SyncEventSessionReasoningEnded = { type: "sync" id: string syncEvent: { - type: "reasoning.ended.1" + type: "session.reasoning.ended.1" id: string seq: number aggregateID: string @@ -4010,11 +3936,11 @@ export type SyncEventReasoningEnded = { } } -export type SyncEventToolInputStarted = { +export type SyncEventSessionToolInputStarted = { type: "sync" id: string syncEvent: { - type: "tool.input.started.1" + type: "session.tool.input.started.1" id: string seq: number aggregateID: string @@ -4027,11 +3953,11 @@ export type SyncEventToolInputStarted = { } } -export type SyncEventToolInputEnded = { +export type SyncEventSessionToolInputEnded = { type: "sync" id: string syncEvent: { - type: "tool.input.ended.1" + type: "session.tool.input.ended.1" id: string seq: number aggregateID: string @@ -4044,11 +3970,11 @@ export type SyncEventToolInputEnded = { } } -export type SyncEventToolCalled = { +export type SyncEventSessionToolCalled = { type: "sync" id: string syncEvent: { - type: "tool.called.1" + type: "session.tool.called.1" id: string seq: number aggregateID: string @@ -4068,11 +3994,11 @@ export type SyncEventToolCalled = { } } -export type SyncEventToolProgress = { +export type SyncEventSessionToolProgress = { type: "sync" id: string syncEvent: { - type: "tool.progress.1" + type: "session.tool.progress.1" id: string seq: number aggregateID: string @@ -4088,11 +4014,11 @@ export type SyncEventToolProgress = { } } -export type SyncEventToolSuccess = { +export type SyncEventSessionToolSuccess = { type: "sync" id: string syncEvent: { - type: "tool.success.1" + type: "session.tool.success.1" id: string seq: number aggregateID: string @@ -4114,11 +4040,11 @@ export type SyncEventToolSuccess = { } } -export type SyncEventToolFailed = { +export type SyncEventSessionToolFailed = { type: "sync" id: string syncEvent: { - type: "tool.failed.1" + type: "session.tool.failed.1" id: string seq: number aggregateID: string @@ -4136,11 +4062,11 @@ export type SyncEventToolFailed = { } } -export type SyncEventRetried = { +export type SyncEventSessionRetried = { type: "sync" id: string syncEvent: { - type: "retried.1" + type: "session.retried.1" id: string seq: number aggregateID: string @@ -4152,11 +4078,11 @@ export type SyncEventRetried = { } } -export type SyncEventCompactionStarted = { +export type SyncEventSessionCompactionStarted = { type: "sync" id: string syncEvent: { - type: "compaction.started.1" + type: "session.compaction.started.1" id: string seq: number aggregateID: string @@ -4167,11 +4093,11 @@ export type SyncEventCompactionStarted = { } } -export type SyncEventCompactionEnded = { +export type SyncEventSessionCompactionEnded = { type: "sync" id: string syncEvent: { - type: "compaction.ended.1" + type: "session.compaction.ended.1" id: string seq: number aggregateID: string @@ -4184,11 +4110,11 @@ export type SyncEventCompactionEnded = { } } -export type SyncEventRevertStaged = { +export type SyncEventSessionRevertStaged = { type: "sync" id: string syncEvent: { - type: "revert.staged.1" + type: "session.revert.staged.1" id: string seq: number aggregateID: string @@ -4199,11 +4125,11 @@ export type SyncEventRevertStaged = { } } -export type SyncEventRevertCleared = { +export type SyncEventSessionRevertCleared = { type: "sync" id: string syncEvent: { - type: "revert.cleared.1" + type: "session.revert.cleared.1" id: string seq: number aggregateID: string @@ -4213,11 +4139,11 @@ export type SyncEventRevertCleared = { } } -export type SyncEventRevertCommitted = { +export type SyncEventSessionRevertCommitted = { type: "sync" id: string syncEvent: { - type: "revert.committed.1" + type: "session.revert.committed.1" id: string seq: number aggregateID: string @@ -4449,9 +4375,13 @@ export type SessionMessageShell = { completed?: number } type: "shell" - callID: string - command: string - output: string + shell: Shell + output?: { + output: string + cursor: number + size: number + truncated: boolean + } } export type SessionMessageAssistantText = { @@ -4600,13 +4530,13 @@ export type SessionContextEntryInfo = { value: unknown } -export type AgentSelected = { +export type SessionAgentSelected = { id: string created: number metadata?: { [key: string]: unknown } - type: "agent.selected" + type: "session.agent.selected" durable: { aggregateID: string seq: number @@ -4619,13 +4549,13 @@ export type AgentSelected = { } } -export type ModelSelected = { +export type SessionModelSelected = { id: string created: number metadata?: { [key: string]: unknown } - type: "model.selected" + type: "session.model.selected" durable: { aggregateID: string seq: number @@ -4658,13 +4588,52 @@ export type SessionMoved = { } } -export type PromptPromoted = { +export type SessionRenamed = { id: string created: number metadata?: { [key: string]: unknown } - type: "prompt.promoted" + type: "session.renamed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + title: string + } +} + +export type SessionForked = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.forked" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + parentID: string + from?: string + } +} + +export type SessionPromptPromoted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.promoted" durable: { aggregateID: string seq: number @@ -4677,13 +4646,13 @@ export type PromptPromoted = { } } -export type PromptAdmitted = { +export type SessionPromptAdmitted = { id: string created: number metadata?: { [key: string]: unknown } - type: "prompt.admitted" + type: "session.prompt.admitted" durable: { aggregateID: string seq: number @@ -4717,13 +4686,36 @@ export type SessionContextUpdated = { } } -export type SkillActivated = { +export type SessionSynthetic = { id: string created: number metadata?: { [key: string]: unknown } - type: "skill.activated" + type: "session.synthetic" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + text: string + description?: string + metadata?: { + [key: string]: unknown + } + } +} + +export type SessionSkillActivated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.skill.activated" durable: { aggregateID: string seq: number @@ -4737,13 +4729,13 @@ export type SkillActivated = { } } -export type ShellStarted = { +export type SessionShellStarted = { id: string created: number metadata?: { [key: string]: unknown } - type: "shell.started" + type: "session.shell.started" durable: { aggregateID: string seq: number @@ -4752,18 +4744,17 @@ export type ShellStarted = { location?: LocationRef data: { sessionID: string - callID: string - command: string + shell: Shell1 } } -export type ShellEnded = { +export type SessionShellEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "shell.ended" + type: "session.shell.ended" durable: { aggregateID: string seq: number @@ -4772,18 +4763,23 @@ export type ShellEnded = { location?: LocationRef data: { sessionID: string - callID: string - output: string + shell: Shell1 + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } -export type StepStarted = { +export type SessionStepStarted = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.started" + type: "session.step.started" durable: { aggregateID: string seq: number @@ -4799,13 +4795,13 @@ export type StepStarted = { } } -export type StepEnded = { +export type SessionStepEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.ended" + type: "session.step.ended" durable: { aggregateID: string seq: number @@ -4831,13 +4827,13 @@ export type StepEnded = { } } -export type StepFailed = { +export type SessionStepFailed = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.failed" + type: "session.step.failed" durable: { aggregateID: string seq: number @@ -4851,13 +4847,13 @@ export type StepFailed = { } } -export type TextStarted = { +export type SessionTextStarted = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.started" + type: "session.text.started" durable: { aggregateID: string seq: number @@ -4871,13 +4867,13 @@ export type TextStarted = { } } -export type TextEnded = { +export type SessionTextEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.ended" + type: "session.text.ended" durable: { aggregateID: string seq: number @@ -4892,13 +4888,13 @@ export type TextEnded = { } } -export type ReasoningStarted = { +export type SessionReasoningStarted = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.started" + type: "session.reasoning.started" durable: { aggregateID: string seq: number @@ -4913,13 +4909,13 @@ export type ReasoningStarted = { } } -export type ReasoningEnded = { +export type SessionReasoningEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.ended" + type: "session.reasoning.ended" durable: { aggregateID: string seq: number @@ -4935,13 +4931,13 @@ export type ReasoningEnded = { } } -export type ToolInputStarted = { +export type SessionToolInputStarted = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.started" + type: "session.tool.input.started" durable: { aggregateID: string seq: number @@ -4956,13 +4952,13 @@ export type ToolInputStarted = { } } -export type ToolInputEnded = { +export type SessionToolInputEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.ended" + type: "session.tool.input.ended" durable: { aggregateID: string seq: number @@ -4977,13 +4973,13 @@ export type ToolInputEnded = { } } -export type ToolCalled = { +export type SessionToolCalled = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.called" + type: "session.tool.called" durable: { aggregateID: string seq: number @@ -5005,13 +5001,13 @@ export type ToolCalled = { } } -export type ToolProgress = { +export type SessionToolProgress = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.progress" + type: "session.tool.progress" durable: { aggregateID: string seq: number @@ -5029,13 +5025,13 @@ export type ToolProgress = { } } -export type ToolSuccess = { +export type SessionToolSuccess = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.success" + type: "session.tool.success" durable: { aggregateID: string seq: number @@ -5059,13 +5055,13 @@ export type ToolSuccess = { } } -export type ToolFailed = { +export type SessionToolFailed = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.failed" + type: "session.tool.failed" durable: { aggregateID: string seq: number @@ -5085,13 +5081,33 @@ export type ToolFailed = { } } -export type CompactionStarted = { +export type SessionRetried = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.started" + type: "session.retried" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + attempt: number + error: SessionRetryError + } +} + +export type SessionCompactionStarted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.started" durable: { aggregateID: string seq: number @@ -5104,13 +5120,13 @@ export type CompactionStarted = { } } -export type CompactionEnded = { +export type SessionCompactionEnded = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.ended" + type: "session.compaction.ended" durable: { aggregateID: string seq: number @@ -5125,13 +5141,13 @@ export type CompactionEnded = { } } -export type RevertStaged = { +export type SessionRevertStaged = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.staged" + type: "session.revert.staged" durable: { aggregateID: string seq: number @@ -5144,13 +5160,13 @@ export type RevertStaged = { } } -export type RevertCleared = { +export type SessionRevertCleared = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.cleared" + type: "session.revert.cleared" durable: { aggregateID: string seq: number @@ -5162,13 +5178,13 @@ export type RevertCleared = { } } -export type RevertCommitted = { +export type SessionRevertCommitted = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.committed" + type: "session.revert.committed" durable: { aggregateID: string seq: number @@ -5708,13 +5724,13 @@ export type MessagePartRemoved = { } } -export type ExecutionSettled = { +export type SessionExecutionSettled = { id: string created: number metadata?: { [key: string]: unknown } - type: "execution.settled" + type: "session.execution.settled" location?: LocationRef data: { sessionID: string @@ -5723,13 +5739,13 @@ export type ExecutionSettled = { } } -export type TextDelta = { +export type SessionTextDelta = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.delta" + type: "session.text.delta" location?: LocationRef data: { sessionID: string @@ -5739,13 +5755,13 @@ export type TextDelta = { } } -export type ReasoningDelta = { +export type SessionReasoningDelta = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.delta" + type: "session.reasoning.delta" location?: LocationRef data: { sessionID: string @@ -5755,13 +5771,13 @@ export type ReasoningDelta = { } } -export type ToolInputDelta = { +export type SessionToolInputDelta = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.delta" + type: "session.tool.input.delta" location?: LocationRef data: { sessionID: string @@ -5771,13 +5787,13 @@ export type ToolInputDelta = { } } -export type CompactionDelta = { +export type SessionCompactionDelta = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.delta" + type: "session.compaction.delta" location?: LocationRef data: { sessionID: string @@ -6730,18 +6746,18 @@ export type EventMessagePartRemoved = { } } -export type EventAgentSelected = { +export type EventSessionAgentSelected = { id: string - type: "agent.selected" + type: "session.agent.selected" properties: { sessionID: string agent: string } } -export type EventModelSelected = { +export type EventSessionModelSelected = { id: string - type: "model.selected" + type: "session.model.selected" properties: { sessionID: string model: ModelRef @@ -6758,18 +6774,18 @@ export type EventSessionMoved = { } } -export type EventRenamed = { +export type EventSessionRenamed = { id: string - type: "renamed" + type: "session.renamed" properties: { sessionID: string title: string } } -export type EventForked = { +export type EventSessionForked = { id: string - type: "forked" + type: "session.forked" properties: { sessionID: string parentID: string @@ -6777,18 +6793,18 @@ export type EventForked = { } } -export type EventPromptPromoted = { +export type EventSessionPromptPromoted = { id: string - type: "prompt.promoted" + type: "session.prompt.promoted" properties: { sessionID: string inputID: string } } -export type EventPromptAdmitted = { +export type EventSessionPromptAdmitted = { id: string - type: "prompt.admitted" + type: "session.prompt.admitted" properties: { sessionID: string inputID: string @@ -6797,9 +6813,9 @@ export type EventPromptAdmitted = { } } -export type EventExecutionSettled = { +export type EventSessionExecutionSettled = { id: string - type: "execution.settled" + type: "session.execution.settled" properties: { sessionID: string outcome: "success" | "failure" | "interrupted" @@ -6816,9 +6832,9 @@ export type EventSessionContextUpdated = { } } -export type EventSynthetic = { +export type EventSessionSynthetic = { id: string - type: "synthetic" + type: "session.synthetic" properties: { sessionID: string text: string @@ -6829,9 +6845,9 @@ export type EventSynthetic = { } } -export type EventSkillActivated = { +export type EventSessionSkillActivated = { id: string - type: "skill.activated" + type: "session.skill.activated" properties: { sessionID: string name: string @@ -6839,29 +6855,33 @@ export type EventSkillActivated = { } } -export type EventShellStarted = { +export type EventSessionShellStarted = { id: string - type: "shell.started" + type: "session.shell.started" properties: { sessionID: string - callID: string - command: string + shell: Shell2 } } -export type EventShellEnded = { +export type EventSessionShellEnded = { id: string - type: "shell.ended" + type: "session.shell.ended" properties: { sessionID: string - callID: string - output: string + shell: Shell2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } -export type EventStepStarted = { +export type EventSessionStepStarted = { id: string - type: "step.started" + type: "session.step.started" properties: { sessionID: string assistantMessageID: string @@ -6871,9 +6891,9 @@ export type EventStepStarted = { } } -export type EventStepEnded = { +export type EventSessionStepEnded = { id: string - type: "step.ended" + type: "session.step.ended" properties: { sessionID: string assistantMessageID: string @@ -6893,9 +6913,9 @@ export type EventStepEnded = { } } -export type EventStepFailed = { +export type EventSessionStepFailed = { id: string - type: "step.failed" + type: "session.step.failed" properties: { sessionID: string assistantMessageID: string @@ -6903,9 +6923,9 @@ export type EventStepFailed = { } } -export type EventTextStarted = { +export type EventSessionTextStarted = { id: string - type: "text.started" + type: "session.text.started" properties: { sessionID: string assistantMessageID: string @@ -6913,9 +6933,9 @@ export type EventTextStarted = { } } -export type EventTextDelta = { +export type EventSessionTextDelta = { id: string - type: "text.delta" + type: "session.text.delta" properties: { sessionID: string assistantMessageID: string @@ -6924,9 +6944,9 @@ export type EventTextDelta = { } } -export type EventTextEnded = { +export type EventSessionTextEnded = { id: string - type: "text.ended" + type: "session.text.ended" properties: { sessionID: string assistantMessageID: string @@ -6935,9 +6955,9 @@ export type EventTextEnded = { } } -export type EventReasoningStarted = { +export type EventSessionReasoningStarted = { id: string - type: "reasoning.started" + type: "session.reasoning.started" properties: { sessionID: string assistantMessageID: string @@ -6946,9 +6966,9 @@ export type EventReasoningStarted = { } } -export type EventReasoningDelta = { +export type EventSessionReasoningDelta = { id: string - type: "reasoning.delta" + type: "session.reasoning.delta" properties: { sessionID: string assistantMessageID: string @@ -6957,9 +6977,9 @@ export type EventReasoningDelta = { } } -export type EventReasoningEnded = { +export type EventSessionReasoningEnded = { id: string - type: "reasoning.ended" + type: "session.reasoning.ended" properties: { sessionID: string assistantMessageID: string @@ -6969,9 +6989,9 @@ export type EventReasoningEnded = { } } -export type EventToolInputStarted = { +export type EventSessionToolInputStarted = { id: string - type: "tool.input.started" + type: "session.tool.input.started" properties: { sessionID: string assistantMessageID: string @@ -6980,9 +7000,9 @@ export type EventToolInputStarted = { } } -export type EventToolInputDelta = { +export type EventSessionToolInputDelta = { id: string - type: "tool.input.delta" + type: "session.tool.input.delta" properties: { sessionID: string assistantMessageID: string @@ -6991,9 +7011,9 @@ export type EventToolInputDelta = { } } -export type EventToolInputEnded = { +export type EventSessionToolInputEnded = { id: string - type: "tool.input.ended" + type: "session.tool.input.ended" properties: { sessionID: string assistantMessageID: string @@ -7002,9 +7022,9 @@ export type EventToolInputEnded = { } } -export type EventToolCalled = { +export type EventSessionToolCalled = { id: string - type: "tool.called" + type: "session.tool.called" properties: { sessionID: string assistantMessageID: string @@ -7020,9 +7040,9 @@ export type EventToolCalled = { } } -export type EventToolProgress = { +export type EventSessionToolProgress = { id: string - type: "tool.progress" + type: "session.tool.progress" properties: { sessionID: string assistantMessageID: string @@ -7034,9 +7054,9 @@ export type EventToolProgress = { } } -export type EventToolSuccess = { +export type EventSessionToolSuccess = { id: string - type: "tool.success" + type: "session.tool.success" properties: { sessionID: string assistantMessageID: string @@ -7054,9 +7074,9 @@ export type EventToolSuccess = { } } -export type EventToolFailed = { +export type EventSessionToolFailed = { id: string - type: "tool.failed" + type: "session.tool.failed" properties: { sessionID: string assistantMessageID: string @@ -7070,9 +7090,9 @@ export type EventToolFailed = { } } -export type EventRetried = { +export type EventSessionRetried = { id: string - type: "retried" + type: "session.retried" properties: { sessionID: string attempt: number @@ -7080,27 +7100,27 @@ export type EventRetried = { } } -export type EventCompactionStarted = { +export type EventSessionCompactionStarted = { id: string - type: "compaction.started" + type: "session.compaction.started" properties: { sessionID: string reason: "auto" | "manual" } } -export type EventCompactionDelta = { +export type EventSessionCompactionDelta = { id: string - type: "compaction.delta" + type: "session.compaction.delta" properties: { sessionID: string text: string } } -export type EventCompactionEnded = { +export type EventSessionCompactionEnded = { id: string - type: "compaction.ended" + type: "session.compaction.ended" properties: { sessionID: string reason: "auto" | "manual" @@ -7109,26 +7129,26 @@ export type EventCompactionEnded = { } } -export type EventRevertStaged = { +export type EventSessionRevertStaged = { id: string - type: "revert.staged" + type: "session.revert.staged" properties: { sessionID: string revert: RevertState } } -export type EventRevertCleared = { +export type EventSessionRevertCleared = { id: string - type: "revert.cleared" + type: "session.revert.cleared" properties: { sessionID: string } } -export type EventRevertCommitted = { +export type EventSessionRevertCommitted = { id: string - type: "revert.committed" + type: "session.revert.committed" properties: { sessionID: string messageID: string @@ -7999,6 +8019,24 @@ export type SessionMessageSkill2 = { text: string } +export type ShellV2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + export type SessionMessageShell2 = { id: string metadata?: { @@ -8009,9 +8047,13 @@ export type SessionMessageShell2 = { completed?: number } type: "shell" - callID: string - command: string - output: string + shell: ShellV2 + output?: { + output: string + cursor: number + size: number + truncated: boolean + } } export type SessionMessageAssistantText2 = { @@ -8188,13 +8230,13 @@ export type SessionContextEntryInfo2 = { value: unknown } -export type AgentSelected2 = { +export type SessionAgentSelected2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "agent.selected" + type: "session.agent.selected" durable: { aggregateID: string seq: number @@ -8207,13 +8249,13 @@ export type AgentSelected2 = { } } -export type ModelSelected2 = { +export type SessionModelSelected2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "model.selected" + type: "session.model.selected" durable: { aggregateID: string seq: number @@ -8246,13 +8288,13 @@ export type SessionMoved2 = { } } -export type RenamedV2 = { +export type SessionRenamed2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "renamed" + type: "session.renamed" durable: { aggregateID: string seq: number @@ -8265,13 +8307,13 @@ export type RenamedV2 = { } } -export type ForkedV2 = { +export type SessionForked2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "forked" + type: "session.forked" durable: { aggregateID: string seq: number @@ -8285,13 +8327,13 @@ export type ForkedV2 = { } } -export type PromptPromoted2 = { +export type SessionPromptPromoted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "prompt.promoted" + type: "session.prompt.promoted" durable: { aggregateID: string seq: number @@ -8304,13 +8346,13 @@ export type PromptPromoted2 = { } } -export type PromptAdmitted2 = { +export type SessionPromptAdmitted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "prompt.admitted" + type: "session.prompt.admitted" durable: { aggregateID: string seq: number @@ -8344,13 +8386,13 @@ export type SessionContextUpdated2 = { } } -export type SyntheticV2 = { +export type SessionSynthetic2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "synthetic" + type: "session.synthetic" durable: { aggregateID: string seq: number @@ -8367,13 +8409,13 @@ export type SyntheticV2 = { } } -export type SkillActivated2 = { +export type SessionSkillActivated2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "skill.activated" + type: "session.skill.activated" durable: { aggregateID: string seq: number @@ -8387,13 +8429,31 @@ export type SkillActivated2 = { } } -export type ShellStarted2 = { +export type Shell1V2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" + } +} + +export type SessionShellStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "shell.started" + type: "session.shell.started" durable: { aggregateID: string seq: number @@ -8402,18 +8462,17 @@ export type ShellStarted2 = { location?: LocationRef2 data: { sessionID: string - callID: string - command: string + shell: Shell1V2 } } -export type ShellEnded2 = { +export type SessionShellEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "shell.ended" + type: "session.shell.ended" durable: { aggregateID: string seq: number @@ -8422,18 +8481,23 @@ export type ShellEnded2 = { location?: LocationRef2 data: { sessionID: string - callID: string - output: string + shell: Shell1V2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } -export type StepStarted2 = { +export type SessionStepStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.started" + type: "session.step.started" durable: { aggregateID: string seq: number @@ -8449,13 +8513,13 @@ export type StepStarted2 = { } } -export type StepEnded2 = { +export type SessionStepEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.ended" + type: "session.step.ended" durable: { aggregateID: string seq: number @@ -8481,13 +8545,13 @@ export type StepEnded2 = { } } -export type StepFailed2 = { +export type SessionStepFailed2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "step.failed" + type: "session.step.failed" durable: { aggregateID: string seq: number @@ -8501,13 +8565,13 @@ export type StepFailed2 = { } } -export type TextStarted2 = { +export type SessionTextStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.started" + type: "session.text.started" durable: { aggregateID: string seq: number @@ -8521,13 +8585,13 @@ export type TextStarted2 = { } } -export type TextEnded2 = { +export type SessionTextEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.ended" + type: "session.text.ended" durable: { aggregateID: string seq: number @@ -8548,13 +8612,13 @@ export type LlmProviderMetadata3 = { } } -export type ReasoningStarted2 = { +export type SessionReasoningStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.started" + type: "session.reasoning.started" durable: { aggregateID: string seq: number @@ -8575,13 +8639,13 @@ export type LlmProviderMetadata4 = { } } -export type ReasoningEnded2 = { +export type SessionReasoningEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.ended" + type: "session.reasoning.ended" durable: { aggregateID: string seq: number @@ -8597,13 +8661,13 @@ export type ReasoningEnded2 = { } } -export type ToolInputStarted2 = { +export type SessionToolInputStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.started" + type: "session.tool.input.started" durable: { aggregateID: string seq: number @@ -8618,13 +8682,13 @@ export type ToolInputStarted2 = { } } -export type ToolInputEnded2 = { +export type SessionToolInputEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.ended" + type: "session.tool.input.ended" durable: { aggregateID: string seq: number @@ -8645,13 +8709,13 @@ export type LlmProviderMetadata5 = { } } -export type ToolCalled2 = { +export type SessionToolCalled2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.called" + type: "session.tool.called" durable: { aggregateID: string seq: number @@ -8673,13 +8737,13 @@ export type ToolCalled2 = { } } -export type ToolProgress2 = { +export type SessionToolProgress2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.progress" + type: "session.tool.progress" durable: { aggregateID: string seq: number @@ -8703,13 +8767,13 @@ export type LlmProviderMetadata6 = { } } -export type ToolSuccess2 = { +export type SessionToolSuccess2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.success" + type: "session.tool.success" durable: { aggregateID: string seq: number @@ -8739,13 +8803,13 @@ export type LlmProviderMetadata7 = { } } -export type ToolFailed2 = { +export type SessionToolFailed2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.failed" + type: "session.tool.failed" durable: { aggregateID: string seq: number @@ -8778,13 +8842,13 @@ export type SessionRetryError2 = { } } -export type RetriedV2 = { +export type SessionRetried2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "retried" + type: "session.retried" durable: { aggregateID: string seq: number @@ -8798,13 +8862,13 @@ export type RetriedV2 = { } } -export type CompactionStarted2 = { +export type SessionCompactionStarted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.started" + type: "session.compaction.started" durable: { aggregateID: string seq: number @@ -8817,13 +8881,13 @@ export type CompactionStarted2 = { } } -export type CompactionEnded2 = { +export type SessionCompactionEnded2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.ended" + type: "session.compaction.ended" durable: { aggregateID: string seq: number @@ -8838,13 +8902,13 @@ export type CompactionEnded2 = { } } -export type RevertStaged2 = { +export type SessionRevertStaged2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.staged" + type: "session.revert.staged" durable: { aggregateID: string seq: number @@ -8857,13 +8921,13 @@ export type RevertStaged2 = { } } -export type RevertCleared2 = { +export type SessionRevertCleared2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.cleared" + type: "session.revert.cleared" durable: { aggregateID: string seq: number @@ -8875,13 +8939,13 @@ export type RevertCleared2 = { } } -export type RevertCommitted2 = { +export type SessionRevertCommitted2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "revert.committed" + type: "session.revert.committed" durable: { aggregateID: string seq: number @@ -8895,37 +8959,37 @@ export type RevertCommitted2 = { } export type SessionDurableEventV2 = - | AgentSelected2 - | ModelSelected2 + | SessionAgentSelected2 + | SessionModelSelected2 | SessionMoved2 - | RenamedV2 - | ForkedV2 - | PromptPromoted2 - | PromptAdmitted2 + | SessionRenamed2 + | SessionForked2 + | SessionPromptPromoted2 + | SessionPromptAdmitted2 | SessionContextUpdated2 - | SyntheticV2 - | SkillActivated2 - | ShellStarted2 - | ShellEnded2 - | StepStarted2 - | StepEnded2 - | StepFailed2 - | TextStarted2 - | TextEnded2 - | ReasoningStarted2 - | ReasoningEnded2 - | ToolInputStarted2 - | ToolInputEnded2 - | ToolCalled2 - | ToolProgress2 - | ToolSuccess2 - | ToolFailed2 - | RetriedV2 - | CompactionStarted2 - | CompactionEnded2 - | RevertStaged2 - | RevertCleared2 - | RevertCommitted2 + | SessionSynthetic2 + | SessionSkillActivated2 + | SessionShellStarted2 + | SessionShellEnded2 + | SessionStepStarted2 + | SessionStepEnded2 + | SessionStepFailed2 + | SessionTextStarted2 + | SessionTextEnded2 + | SessionReasoningStarted2 + | SessionReasoningEnded2 + | SessionToolInputStarted2 + | SessionToolInputEnded2 + | SessionToolCalled2 + | SessionToolProgress2 + | SessionToolSuccess2 + | SessionToolFailed2 + | SessionRetried2 + | SessionCompactionStarted2 + | SessionCompactionEnded2 + | SessionRevertStaged2 + | SessionRevertCleared2 + | SessionRevertCommitted2 /** * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. @@ -10138,13 +10202,13 @@ export type MessagePartRemoved2 = { } } -export type ExecutionSettled2 = { +export type SessionExecutionSettled2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "execution.settled" + type: "session.execution.settled" location?: LocationRef2 data: { sessionID: string @@ -10153,13 +10217,13 @@ export type ExecutionSettled2 = { } } -export type TextDelta2 = { +export type SessionTextDelta2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "text.delta" + type: "session.text.delta" location?: LocationRef2 data: { sessionID: string @@ -10169,13 +10233,13 @@ export type TextDelta2 = { } } -export type ReasoningDelta2 = { +export type SessionReasoningDelta2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "reasoning.delta" + type: "session.reasoning.delta" location?: LocationRef2 data: { sessionID: string @@ -10185,13 +10249,13 @@ export type ReasoningDelta2 = { } } -export type ToolInputDelta2 = { +export type SessionToolInputDelta2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "tool.input.delta" + type: "session.tool.input.delta" location?: LocationRef2 data: { sessionID: string @@ -10201,13 +10265,13 @@ export type ToolInputDelta2 = { } } -export type CompactionDelta2 = { +export type SessionCompactionDelta2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "compaction.delta" + type: "session.compaction.delta" location?: LocationRef2 data: { sessionID: string @@ -10413,24 +10477,6 @@ export type PtyDeleted2 = { } } -export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type ShellCreated2 = { id: string created: number @@ -10440,7 +10486,7 @@ export type ShellCreated2 = { type: "shell.created" location?: LocationRef2 data: { - info: ShellV2 + info: Shell1V2 } } @@ -11080,42 +11126,42 @@ export type V2EventV2 = | MessageRemoved2 | MessagePartUpdated2 | MessagePartRemoved2 - | AgentSelected2 - | ModelSelected2 + | SessionAgentSelected2 + | SessionModelSelected2 | SessionMoved2 - | RenamedV2 - | ForkedV2 - | PromptPromoted2 - | PromptAdmitted2 - | ExecutionSettled2 + | SessionRenamed2 + | SessionForked2 + | SessionPromptPromoted2 + | SessionPromptAdmitted2 + | SessionExecutionSettled2 | 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 + | SessionSynthetic2 + | SessionSkillActivated2 + | SessionShellStarted2 + | SessionShellEnded2 + | SessionStepStarted2 + | SessionStepEnded2 + | SessionStepFailed2 + | SessionTextStarted2 + | SessionTextDelta2 + | SessionTextEnded2 + | SessionReasoningStarted2 + | SessionReasoningDelta2 + | SessionReasoningEnded2 + | SessionToolInputStarted2 + | SessionToolInputDelta2 + | SessionToolInputEnded2 + | SessionToolCalled2 + | SessionToolProgress2 + | SessionToolSuccess2 + | SessionToolFailed2 + | SessionRetried2 + | SessionCompactionStarted2 + | SessionCompactionDelta2 + | SessionCompactionEnded2 + | SessionRevertStaged2 + | SessionRevertCleared2 + | SessionRevertCommitted2 | FileEdited2 | ReferenceUpdated2 | PermissionV2Asked2 @@ -11195,24 +11241,6 @@ export type ForbiddenErrorV2 = { message: string } -export type Shell1V2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - export type ShellNotFoundErrorV2 = { _tag: "ShellNotFoundError" id: string @@ -18444,7 +18472,7 @@ export type V2ShellListResponses = { */ 200: { location: LocationInfo2 - data: Array + data: Array } } @@ -18488,7 +18516,7 @@ export type V2ShellCreateResponses = { */ 200: { location: LocationInfo2 - data: Shell1V2 + data: ShellV2 } } @@ -18571,7 +18599,7 @@ export type V2ShellGetResponses = { */ 200: { location: LocationInfo2 - data: Shell1V2 + data: ShellV2 } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b30c3beb35..e136ef6836 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -16410,7 +16410,7 @@ "text": { "type": "string" }, - "synthetic": { + "session.synthetic": { "type": "boolean" }, "ignored": { @@ -23071,7 +23071,7 @@ "text": { "type": "string" }, - "synthetic": { + "session.synthetic": { "type": "boolean" }, "ignored": { @@ -27479,7 +27479,7 @@ }, "type": { "type": "string", - "enum": ["synthetic"] + "enum": ["session.synthetic"] } }, "required": ["id", "time", "sessionID", "text", "type"], diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index cc9378dbac..0ed464e561 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -126,8 +126,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - activeShell(messages: SessionMessage[], callID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.callID === callID) + shell(messages: SessionMessage[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) return item?.type === "shell" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -220,7 +220,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "skill.updated": void result.location.skill.refresh(event.location) break - case "agent.selected": + case "session.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) => { @@ -232,7 +232,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "model.selected": + case "session.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) => { @@ -244,11 +244,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "renamed": + case "session.renamed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break - case "prompt.promoted": { + case "session.prompt.promoted": { setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) @@ -264,7 +264,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break } - case "prompt.admitted": + case "session.prompt.admitted": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID, @@ -287,7 +287,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "synthetic": + case "session.synthetic": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -299,29 +299,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "shell.started": + case "session.shell.started": setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - callID: event.data.callID, - command: event.data.command, - output: "", + shell: event.data.shell, time: { created: event.created }, }) }) break - case "shell.ended": + case "session.shell.ended": setStore("session", "status", event.data.sessionID, "idle") - message.update(event.data.sessionID, (draft, index) => { - const match = message.activeShell(draft, event.data.callID) + message.update(event.data.sessionID, (draft) => { + const match = message.shell(draft, event.data.shell.id) if (!match) return + match.shell = event.data.shell match.output = event.data.output match.time.completed = event.created }) break - case "step.started": + case "session.step.started": setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { if (index.has(event.data.assistantMessageID)) return @@ -338,7 +337,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "step.ended": + case "session.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) @@ -351,7 +350,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break - case "step.failed": + case "session.step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -360,7 +359,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.error = event.data.error }) break - case "text.started": + case "session.text.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", @@ -369,7 +368,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "text.delta": + case "session.text.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -378,7 +377,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "text.ended": + case "session.text.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -387,7 +386,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text = event.data.text }) break - case "tool.input.started": + case "session.tool.input.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "tool", @@ -398,7 +397,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "tool.input.delta": + case "session.tool.input.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -407,7 +406,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input += event.data.delta }) break - case "tool.input.ended": + case "session.tool.input.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -416,7 +415,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input = event.data.text }) break - case "tool.called": + case "session.tool.called": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -428,7 +427,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break - case "tool.progress": + case "session.tool.progress": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -439,7 +438,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.state.content = [...event.data.content] }) break - case "tool.success": + case "session.tool.success": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -461,7 +460,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.time.completed = event.created }) break - case "tool.failed": + case "session.tool.failed": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -484,7 +483,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.time.completed = event.created }) break - case "reasoning.started": + case "session.reasoning.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", @@ -495,7 +494,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "reasoning.delta": + case "session.reasoning.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -504,7 +503,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "reasoning.ended": + case "session.reasoning.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -517,25 +516,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } }) break - case "retried": - case "compaction.started": + case "session.retried": + case "session.compaction.started": setStore("session", "status", event.data.sessionID, "running") break - case "execution.settled": + case "session.execution.settled": setStore("session", "status", event.data.sessionID, "idle") break - case "revert.staged": + case "session.revert.staged": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", event.data.revert) break - case "revert.cleared": - case "revert.committed": + case "session.revert.cleared": + case "session.revert.committed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", undefined) break - case "compaction.delta": + case "session.compaction.delta": break - case "compaction.ended": + case "session.compaction.ended": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index aa8dcace19..416ba8c466 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -74,17 +74,17 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - 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) => { + api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID)) + api.event.on("session.shell.started", (event) => started(event.data.sessionID)) + api.event.on("session.step.started", (event) => started(event.data.sessionID)) + api.event.on("session.retried", (event) => started(event.data.sessionID)) + api.event.on("session.compaction.started", (event) => started(event.data.sessionID)) + api.event.on("session.shell.ended", (event) => ended(event.data.sessionID)) + api.event.on("session.step.ended", (event) => { if (event.data.finish === "tool-calls") return ended(event.data.sessionID) }) - api.event.on("step.failed", (event) => { + api.event.on("session.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 b0f337c2d4..b968f13cd3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1346,7 +1346,7 @@ function RevertMessage(props: { function ShellMessage(props: { message: Extract }) { const { theme } = useTheme() - const output = createMemo(() => stripAnsi(props.message.output.trim())) + const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) return ( - $ {props.message.command} + $ {props.message.shell.command} {output()} @@ -1408,13 +1408,7 @@ function UserMessage(props: { message: SessionMessageUser }) { > {props.message.text} - + {(file) => { const directory = file.mime === "application/x-directory" @@ -1744,7 +1738,8 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { return Boolean(shellID && data.shell.get(shellID)) } if (display() === "subagent") { - const sessionID = stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId) + const sessionID = + stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId) return Boolean(sessionID && data.session.status(sessionID) === "running") } return false @@ -2660,7 +2655,8 @@ function formatSessionTranscript( ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] - if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output}\n\`\`\``] + if (message.type === "shell") + return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] const content = message.content.flatMap((item) => { if (item.type === "text") return [item.text] diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 23b43c3b17..783b2805f4 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -133,42 +133,42 @@ export function createSessionRows(sessionID: Accessor) { if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) } const subscriptions = [ - data.on("prompt.admitted", input), - data.on("prompt.promoted", input), + data.on("session.prompt.admitted", input), + data.on("session.prompt.promoted", input), data.on("session.context.updated", message), - data.on("synthetic", (event) => { + data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.id.replace(/^evt_/, "msg_")) }), - 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) => { + data.on("session.shell.started", message), + data.on("session.agent.selected", message), + data.on("session.model.selected", message), + data.on("session.compaction.ended", message), + data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) }), - data.on("text.ended", (event) => { + data.on("session.text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) }), - data.on("reasoning.delta", (event) => { + data.on("session.reasoning.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) }), - data.on("reasoning.ended", (event) => { + data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) }), - data.on("tool.input.started", (event) => { + data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) }), - data.on("step.ended", (event) => { + data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) }), - data.on("step.failed", (event) => { + data.on("session.step.failed", (event) => { if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) }), ] diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 5f1c6355b3..44cd98b133 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -91,7 +91,7 @@ function stepStarted(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "step.started", + type: "session.step.started", durable: durable(sessionID), data: { sessionID, @@ -106,7 +106,7 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event return { id, created: 0, - type: "step.ended", + type: "session.step.ended", durable: durable(sessionID), data: { sessionID, @@ -122,7 +122,7 @@ function stepFailed(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "step.failed", + type: "session.step.failed", durable: durable(sessionID), data: { sessionID, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index a71bdc47d8..0587662dc3 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -269,7 +269,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_started", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("session-live"), data: { sessionID: "session-live", @@ -283,7 +283,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_ended", created: 0, - type: "step.ended", + type: "session.step.ended", durable: durable("session-live", 1, 2), data: { sessionID: "session-live", @@ -302,7 +302,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_execution_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "session-live", outcome: "success", @@ -313,7 +313,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_failed_step_started", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("session-failed"), data: { sessionID: "session-failed", @@ -327,7 +327,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_failed", created: 0, - type: "step.failed", + type: "session.step.failed", durable: durable("session-failed", 1, 2), data: { sessionID: "session-failed", @@ -344,7 +344,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_failed_execution_settled", created: 0, - type: "execution.settled", + type: "session.execution.settled", data: { sessionID: "session-failed", outcome: "failure", @@ -797,14 +797,14 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_agent_1", created: 0, - type: "agent.selected", + type: "session.agent.selected", durable: durable("session-1"), data: { sessionID: "session-1", agent: "build" }, }) emitEvent(events, { id: "evt_model_1", created: 0, - type: "model.selected", + type: "session.model.selected", durable: durable("session-1", 1), data: { sessionID: "session-1", @@ -814,7 +814,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_step_started_1", created: 0, - type: "step.started", + type: "session.step.started", durable: durable("session-1", 2), data: { sessionID: "session-1", @@ -826,7 +826,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_input_1", created: 0, - type: "tool.input.started", + type: "session.tool.input.started", durable: durable("session-1", 3), data: { sessionID: "session-1", @@ -838,7 +838,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_called_1", created: 0, - type: "tool.called", + type: "session.tool.called", durable: durable("session-1", 4), data: { sessionID: "session-1", @@ -852,7 +852,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_failed_1", created: 0, - type: "tool.failed", + type: "session.tool.failed", durable: durable("session-1", 5), data: { sessionID: "session-1", @@ -942,7 +942,7 @@ test("renders admitted prompts immediately with queued marker and clears when pr emitEvent(events, { id: "evt_admitted_1", created: 0, - type: "prompt.admitted", + type: "session.prompt.admitted", durable: durable(sessionID), data: { sessionID, @@ -961,7 +961,7 @@ test("renders admitted prompts immediately with queued marker and clears when pr emitEvent(events, { id: "evt_prompted_1", created: 0, - type: "prompt.promoted", + type: "session.prompt.promoted", durable: durable(sessionID, 1), data: { sessionID, @@ -969,8 +969,8 @@ test("renders admitted prompts immediately with queued marker and clears when pr }, }) - await wait(() => received.at(-1) === "prompt.promoted") - expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"]) + await wait(() => received.at(-1) === "session.prompt.promoted") + expect(received.slice(-2)).toEqual(["session.prompt.admitted", "session.prompt.promoted"]) unsubscribe() const message = sync.session.message.list(sessionID)?.[0] expect(message?.type).toBe("user") diff --git a/packages/ui/src/components/provider-icon.tsx b/packages/ui/src/components/provider-icon.tsx index 7c0eb3d047..46fb7fb6f2 100644 --- a/packages/ui/src/components/provider-icon.tsx +++ b/packages/ui/src/components/provider-icon.tsx @@ -9,7 +9,7 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & { export const ProviderIcon: Component = (props) => { const [local, rest] = splitProps(props, ["id", "class", "classList"]) - const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic")) + const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic")) return ( ✓ Connected - case "failed": - return ✗ {props.status.error} +// Sort by how much attention a server needs: auth prompts first, then failures, +// then healthy servers, and intentionally-off servers last. +function statusMeta(status: McpServer["status"], theme: Theme) { + switch (status.status) { case "needs_auth": - return ! Needs authentication + return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false } case "needs_client_registration": - return ✗ {props.status.error} - case "disabled": - return ○ Disabled + return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false } + case "failed": + return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false } + case "connected": + return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true } + case "pending": + return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false } default: - return ○ Disconnected + return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false } } } export function DialogMcp() { const data = useData() + const dialog = useDialog() + const { theme } = useTheme() + const [expanded, setExpanded] = createStore>({}) + const [focused, setFocused] = createSignal() const [, setRef] = createSignal>() - const options = createMemo(() => + onMount(() => { + dialog.setSize("large") + }) + + const servers = createMemo(() => pipe( data.location.mcp.list() ?? [], - sortBy((server) => server.name), - map((server) => ({ - value: server.name, - title: server.name, - footer: , - category: undefined, - })), + sortBy( + (server) => statusMeta(server.status, theme).rank, + (server) => server.name, + ), ), ) + createEffect(() => { + if (focused()) return + const first = servers()[0] + if (first) setFocused(first.name) + }) + + const options = createMemo(() => + servers().map((server) => { + const meta = statusMeta(server.status, theme) + return { + value: server.name, + title: server.name, + footer: ( + + {meta.icon} {meta.label} + + ), + details: meta.error && expanded[server.name] ? [meta.error] : undefined, + detailsColor: theme.error, + detailsWrap: true, + } + }), + ) + + const focusedError = createMemo(() => { + const name = focused() + const server = servers().find((entry) => entry.name === name) + return server ? statusMeta(server.status, theme).error : undefined + }) + return ( { - // Read-only view: selection does nothing, the dialog closes on escape. + preserveSelection + onMove={(option) => setFocused(option.value as string)} + onSelect={(option) => { + const name = option.value as string + const server = servers().find((entry) => entry.name === name) + if (!server || !statusMeta(server.status, theme).error) return + setExpanded(name, (open) => !open) }} + footer={ + + enter to {expanded[focused()!] ? "hide" : "view"} error + + } /> ) } diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d24..28235531e2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -59,6 +59,8 @@ export interface DialogSelectOption { value: T description?: string details?: string[] + detailsColor?: RGBA + detailsWrap?: boolean footer?: JSX.Element | string titleWidth?: number truncateTitle?: boolean | "left" @@ -697,8 +699,13 @@ export function DialogSelect(props: DialogSelectProps) { {(detail) => ( - - {Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} + + {option.detailsWrap + ? detail + : Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} )} From c9b24ef027dbe5b338123e43ae395892650f8cd3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 20:34:29 -0400 Subject: [PATCH 16/21] feat(core): reload config on filesystem changes --- packages/app/src/context/file/watcher.test.ts | 10 +- packages/app/src/context/file/watcher.ts | 2 +- packages/app/src/pages/session.tsx | 2 +- packages/client/src/effect/index.ts | 1 + packages/client/src/promise/api.ts | 2 + .../client/src/promise/generated/types.ts | 10 +- packages/client/src/promise/index.ts | 1 + packages/core/src/catalog.ts | 15 +- packages/core/src/config.ts | 120 ++++++---- packages/core/src/config/experimental.ts | 20 -- packages/core/src/config/plugin/command.ts | 33 ++- .../core/src/filesystem/location-watcher.ts | 83 +++++++ packages/core/src/filesystem/watcher.ts | 220 ++++++++++-------- packages/core/src/location-services.ts | 6 +- packages/core/src/plugin/host.ts | 9 +- packages/core/src/plugin/promise.ts | 5 +- packages/core/src/policy.ts | 48 ---- packages/core/src/skill.ts | 4 +- packages/core/src/v1/config/config.ts | 4 - packages/core/src/v1/config/migrate.ts | 1 - packages/core/test/catalog.test.ts | 20 +- packages/core/test/config/command.test.ts | 19 +- packages/core/test/config/config.test.ts | 96 ++++---- packages/core/test/filesystem/watcher.test.ts | 28 ++- packages/core/test/location-layer.test.ts | 27 +-- packages/core/test/plugin.test.ts | 23 +- packages/core/test/plugin/host.ts | 5 +- packages/core/test/policy.test.ts | 85 ------- packages/core/test/skill.test.ts | 4 +- packages/opencode/src/tool/apply_patch.ts | 3 +- packages/opencode/src/tool/edit.ts | 5 +- packages/opencode/src/tool/write.ts | 3 +- .../test/server/httpapi-v2-location.test.ts | 2 +- packages/plugin/src/v2/effect/context.ts | 2 + packages/plugin/src/v2/effect/event.ts | 11 +- packages/plugin/src/v2/effect/index.ts | 1 + packages/plugin/src/v2/promise/context.ts | 2 + packages/plugin/src/v2/promise/event.ts | 3 + packages/plugin/src/v2/promise/index.ts | 1 + packages/schema/src/config.ts | 10 + packages/schema/src/event-manifest.ts | 6 +- packages/schema/src/filesystem-v1.ts | 1 + packages/schema/src/filesystem-watcher.ts | 13 -- packages/schema/src/filesystem.ts | 11 +- packages/schema/src/index.ts | 1 + packages/schema/src/v1/filesystem.ts | 11 + packages/schema/test/event-manifest.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 143 +++++++----- 48 files changed, 612 insertions(+), 526 deletions(-) delete mode 100644 packages/core/src/config/experimental.ts create mode 100644 packages/core/src/filesystem/location-watcher.ts delete mode 100644 packages/core/src/policy.ts delete mode 100644 packages/core/test/policy.test.ts create mode 100644 packages/plugin/src/v2/promise/event.ts create mode 100644 packages/schema/src/config.ts create mode 100644 packages/schema/src/filesystem-v1.ts delete mode 100644 packages/schema/src/filesystem-watcher.ts create mode 100644 packages/schema/src/v1/filesystem.ts diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536..dbe745ff7d 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -7,7 +7,7 @@ describe("file watcher invalidation", () => { const refresh: string[] = [] invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/new.ts", event: "add", @@ -32,7 +32,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/open.ts", event: "change", @@ -63,7 +63,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src", event: "change", @@ -81,7 +81,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/file.ts", event: "change", @@ -111,7 +111,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: ".git/index.lock", event: "change", diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf7199279..1dcaeffd25 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -16,7 +16,7 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "file.watcher.updated") return + if (event.type !== "filesystem.changed") return const props = typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 36be14546b..25b2459954 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -820,7 +820,7 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + if (evt.details.type !== "filesystem.changed") return const props = typeof evt.details.properties === "object" && evt.details.properties ? (evt.details.properties as Record) diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 50b85da627..8cde84695c 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -8,6 +8,7 @@ export type { AppApi, CatalogApi, CommandApi, + EventApi, IntegrationApi, ModelApi, PluginApi, diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index a080156942..e7d1c27d25 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -1,6 +1,7 @@ import type { AgentApi as EffectAgentApi, CommandApi as EffectCommandApi, + EventApi as EffectEventApi, IntegrationApi as EffectIntegrationApi, ModelApi as EffectModelApi, PluginApi as EffectPluginApi, @@ -25,6 +26,7 @@ type PromisifyApi = { export type AgentApi = PromisifyApi> export type CommandApi = PromisifyApi> +export type EventApi = PromisifyApi> export type IntegrationApi = PromisifyApi> export type ModelApi = PromisifyApi> export type PluginApi = PromisifyApi> diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e0372cd57e..ff06963036 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4923,9 +4923,9 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.edited" + readonly type: "filesystem.changed" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly file: string } + readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } } | { readonly id: string @@ -4991,7 +4991,7 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "skill.updated" + readonly type: "config.updated" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } @@ -4999,9 +4999,9 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.watcher.updated" + readonly type: "skill.updated" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } + readonly data: {} } | { readonly id: string diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 9203fe5477..fd889c64e5 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -3,6 +3,7 @@ export type { AgentApi, CatalogApi, CommandApi, + EventApi, IntegrationApi, ModelApi, PluginApi, diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 78688ce12c..ab34db8ea4 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,12 +1,11 @@ export * as Catalog from "./catalog" import { makeLocationNode } from "./effect/app-node" -import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" +import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { EventV2 } from "./event" -import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" @@ -17,8 +16,6 @@ export type ProviderRecord = { export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } -export const PolicyActions = Schema.Literals(["provider.use"]) - export const Event = Catalog.Event type Data = { @@ -65,7 +62,6 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const policy = yield* Policy.Service const integrations = yield* Integration.Service const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { @@ -159,13 +155,6 @@ const layer = Layer.effect( return result }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { - if (policy.hasStatements()) { - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) - } - } - } yield* events.publish(Event.Updated, {}) }), }) @@ -294,4 +283,4 @@ const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38..9e829fb740 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,18 +3,19 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { EventV2 } from "./event" +import { Watcher } from "./filesystem/watcher" import { FSUtil } from "./fs-util" import { Global } from "./global" import { Location } from "./location" -import { Policy } from "./policy" import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" -import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" @@ -102,7 +103,6 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered external plugin packages to load", }), - experimental: ConfigExperimental.Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} @@ -138,7 +138,8 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service - const policy = yield* Policy.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) @@ -170,45 +171,78 @@ const layer = Layer.effect( ] }) - const globalDirectory = AbsolutePath.make(global.config) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - // Read configuration once when this location opens. Later calls reuse these - // values until the location is reopened. - const discovered = locationIsGlobal - ? [] - : yield* fs - .up({ - targets: [".opencode", ...names.toReversed()], - start: location.directory, - stop: location.project.directory, - }) - .pipe(Effect.orDie) - const directories = [ - globalDirectory, - ...discovered - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)), + const discover = Effect.fn("Config.discover")(function* () { + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + const discovered = locationIsGlobal + ? [] + : yield* fs + .up({ + targets: [".opencode", ...names.toReversed()], + start: location.directory, + stop: location.project.directory, + }) + .pipe(Effect.orDie) + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + return { + entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories, + files: directPaths, + } + }) + + const initial = yield* discover() + let configs = initial.entries + const updates = yield* PubSub.unbounded() + const subscriptions = new Map>() + const targets = (snapshot: typeof initial) => [ + ...snapshot.directories.map((path) => ({ path, type: "directory" as const })), + ...snapshot.files + .filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file))) + .map((path) => ({ path, type: "file" as const })), ] - // A config closer to the opened directory should win over one higher up. - // Search starts nearby, so reverse the results before applying them. - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() - const direct = yield* Effect.forEach(directPaths, loadFile).pipe( - Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - ) - const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - // Apply general settings first and more specific settings last: - // global config, project files, then `.opencode` files. - const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] - // Rules use the opposite order so a user-global rule can override a - // repository rule. Statement order inside each file stays unchanged. - yield* policy.load( - configs - .filter((config): config is Document => config.type === "document") - .toReversed() - .flatMap((config) => config.info.experimental?.policies ?? []), + const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) { + const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target])) + for (const [key, stop] of subscriptions) { + if (next.has(key)) continue + yield* stop + subscriptions.delete(key) + } + for (const [key, target] of next) { + if (subscriptions.has(key)) continue + const fiber = yield* watcher.subscribe(target).pipe( + Stream.runForEach((update) => PubSub.publish(updates, update)), + Effect.forkScoped({ startImmediately: true }), + ) + subscriptions.set(key, Fiber.interrupt(fiber)) + } + }) + + yield* Stream.fromPubSub(updates).pipe( + Stream.debounce("100 millis"), + Stream.runForEach((update) => + Effect.gen(function* () { + const next = yield* discover() + configs = next.entries + yield* reconcile(next) + yield* events.publish(ConfigSchema.Event.Updated, {}) + }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), + ), + Effect.forkScoped({ startImmediately: true }), ) + yield* reconcile(initial) return Service.of({ entries: Effect.fn("Config.entries")(function* () { @@ -221,5 +255,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [FSUtil.node, Global.node, Location.node, Policy.node], + deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node], }) diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts deleted file mode 100644 index 8b38a225b4..0000000000 --- a/packages/core/src/config/experimental.ts +++ /dev/null @@ -1,20 +0,0 @@ -export * as ConfigExperimental from "./experimental" - -import { Schema } from "effect" -import { Catalog } from "../catalog" -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]) - -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: PolicyConfig.pipe(Schema.Array, Schema.optional), -}) {} diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index bb7a030cbb..43ba4cd375 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command" import { define } from "../../plugin/internal" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" @@ -17,16 +17,19 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) + const load = Effect.fn("ConfigCommandPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } yield* ctx.command.transform((draft) => { - for (const document of documents) { + for (const document of loaded.documents) { for (const [name, command] of Object.entries(document.commands ?? {})) { draft.update(name, (item) => { item.template = command.template @@ -44,6 +47,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.command.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts new file mode 100644 index 0000000000..1566d3d8ff --- /dev/null +++ b/packages/core/src/filesystem/location-watcher.ts @@ -0,0 +1,83 @@ +export * as LocationWatcher from "./location-watcher" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer, Stream } from "effect" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import os from "os" +import path from "path" +import { Config } from "../config" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Git } from "../git" +import { Location } from "../location" +import { Watcher } from "./watcher" +import { Ignore } from "./ignore" +import { Protected } from "./protected" + +function protecteds(dir: string) { + return Protected.paths().filter((item) => { + const relative = path.relative(dir, item) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + }) +} + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/LocationWatcher") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const git = yield* Git.Service + 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 ?? []) + const publish = (update: { type: "create" | "update" | "delete"; path: string }) => + events.publish(FileSystem.Event.Changed, { + file: update.path, + event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink", + }) + + if (path.resolve(location.directory) !== path.resolve(os.homedir())) { + yield* watcher + .subscribe({ + path: location.directory, + type: "directory", + ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], + }) + .pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true })) + } else { + yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory }) + } + + if (location.vcs?.type === "git") { + const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory + const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined + if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { + const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( + (entry) => (entry.name === "HEAD" ? [] : [entry.name]), + ) + yield* watcher + .subscribe({ path: vcs, type: "directory", ignore }) + .pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true })) + } + } + + return Service.of({}) + }).pipe( + Effect.catchCause((cause) => + Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))), + ), + ), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 1efc9d6907..2febe93b76 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,26 +3,20 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { makeLocationNode } from "../effect/app-node" -import { Cause, Context, Effect, Layer } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" -import os from "os" -import path from "path" -import { Config } from "../config" -import { EventV2 } from "../event" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { makeGlobalNode } from "../effect/app-node" +import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect" +import { KeyedMutex } from "../effect/keyed-mutex" import { Flag } from "../flag/flag" -import { FSUtil } from "../fs-util" -import { Git } from "../git" -import { Location } from "../location" import { lazy } from "../util/lazy" -import { Ignore } from "./ignore" -import { Protected } from "./protected" +import { watch as watchFileSystem } from "node:fs" +import path from "path" declare const OPENCODE_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 -export const Event = FileSystemWatcher.Event +export const Event = { Updated: FileSystem.Event.Changed } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { @@ -42,108 +36,132 @@ function getBackend() { if (process.platform === "linux") return "inotify" } -function protecteds(dir: string) { - return Protected.paths().filter((item) => { - const relative = path.relative(dir, item) - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) - }) +export const hasNativeBinding = () => !!watcher() +export type Update = ParcelWatcher.Event + +export type WatchInput = + | { readonly path: string; readonly type: "file" } + | { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] } + +export interface Interface { + readonly subscribe: (input: WatchInput) => Stream.Stream } -export const hasNativeBinding = () => !!watcher() - -export interface Interface {} - -export class Service extends Context.Service()("@opencode/v2/FileWatcher") {} +export class Service extends Context.Service()("@opencode/Watcher") {} const layer = Layer.effect( Service, Effect.gen(function* () { - if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({}) - const backend = getBackend() - const location = yield* Location.Service - if (path.resolve(location.directory) === path.resolve(os.homedir())) { - yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) - return Service.of({}) - } - if (!backend) { - yield* Effect.logError("watcher backend not supported", { - directory: location.directory, - platform: process.platform, - }) - return Service.of({}) + const native = watcher() + if (Flag.OPENCODE_DISABLE_FILEWATCHER) { + return Service.of({ subscribe: () => Stream.empty }) } - const w = watcher() - if (!w) return Service.of({}) - - yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const git = yield* Git.Service - const context = yield* Effect.context() - const runFork = Effect.runForkWith(context) - const subscriptions: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => - Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))), - ) - - const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { - if (_error) runFork(Effect.logError("watcher callback failed", { error: _error })) - for (const update of updates) { - if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) - if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) - if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" })) - } + type Entry = { + readonly pubsub: PubSub.PubSub + readonly subscription: { readonly unsubscribe: () => Promise } + refs: number } + const entries = new Map() + const locks = KeyedMutex.makeUnsafe() - const subscribe = (directory: string, ignore: string[]) => { - const pending = w.subscribe(directory, callback, { ignore, backend }) - return Effect.promise(() => pending).pipe( - Effect.tap((subscription) => - Effect.sync(() => subscriptions.push(subscription)).pipe( - Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), - ), - ), - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), - Effect.catchCause((cause) => { - pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) - return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) + const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) { + const scope = yield* Scope.Scope + const target = path.resolve(input.path) + const directory = input.type === "file" ? path.dirname(target) : target + const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted() + const id = JSON.stringify([input.type, target, ignore]) + const pubsub = yield* locks.withLock(id)( + Effect.gen(function* () { + const existing = entries.get(id) + if (existing) { + existing.refs++ + return existing.pubsub + } + const pubsub = yield* PubSub.unbounded() + const subscription = yield* input.type === "file" + ? Effect.sync(() => { + const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => { + if (file && path.resolve(directory, file.toString()) !== target) return + PubSub.publishUnsafe(pubsub, { + path: target, + type: "update", + } satisfies Update) + }) + subscription.on("error", (error) => + Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })), + ) + return { unsubscribe: () => Promise.resolve(subscription.close()) } + }) + : subscribeDirectory(native, backend, directory, ignore, pubsub) + if (subscription) { + entries.set(id, { pubsub, subscription, refs: 1 }) + yield* Effect.logInfo("watcher started", { + path: target, + type: input.type, + backend: input.type === "file" ? "node" : backend, + ignores: ignore.length, + }) + return pubsub + } + yield* PubSub.shutdown(pubsub) + return pubsub }), ) - } - 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( - subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), - ) - - if (location.vcs?.type === "git") { - const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory - const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined - if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { - const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( - (entry) => (entry.name === "HEAD" ? [] : [entry.name]), - ) - yield* Effect.forkScoped(subscribe(vcs, ignore)) - } - } - - return Service.of({}) - }).pipe( - Effect.catchCause((cause) => { - return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( - Effect.as(Service.of({})), + yield* Scope.addFinalizer( + scope, + locks.withLock(id)( + Effect.gen(function* () { + const entry = entries.get(id) + if (!entry) return + entry.refs-- + if (entry.refs > 0) return + entries.delete(id) + yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore) + yield* PubSub.shutdown(entry.pubsub) + yield* Effect.logInfo("watcher stopped", { path: target, type: input.type }) + }), + ), ) - }), - ), + return pubsub + }) + + const subscribe = (input: WatchInput) => + Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))) + + return Service.of({ subscribe }) + }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], -}) +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) + +function subscribeDirectory( + native: typeof import("@parcel/watcher") | undefined, + backend: ParcelWatcher.BackendType | undefined, + directory: string, + ignore: string[], + pubsub: PubSub.PubSub, +) { + if (!native || !backend) { + return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe( + Effect.as(undefined), + ) + } + const callback: ParcelWatcher.SubscribeCallback = (error, updates) => { + if (error) Effect.runFork(Effect.logError("watcher callback failed", { error })) + for (const update of updates) PubSub.publishUnsafe(pubsub, update) + } + const pending = native.subscribe(directory, callback, { ignore, backend }) + return Effect.promise(() => pending).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) + return Effect.logError("failed to subscribe", { + directory, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)) + }), + ) +} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index f0f2d63e7f..2a4e0d6ef6 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" import { Generate } from "./generate" import { Form } from "./form" -import { Watcher } from "./filesystem/watcher" +import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" import { Integration } from "./integration" import { Location } from "./location" @@ -21,7 +21,6 @@ import { MCP } from "./mcp/index" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginInternal } from "./plugin/internal" -import { Policy } from "./policy" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" @@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map" const locationServiceNodes = [ Location.node, - Policy.node, Config.node, AgentV2.node, CommandV2.node, @@ -64,7 +62,7 @@ const locationServiceNodes = [ ProjectCopy.refreshNode, FileSystemSearch.node, FileSystem.node, - Watcher.node, + LocationWatcher.node, Pty.node, Shell.node, SkillV2.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index bf3420a023..d25555eb37 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,12 +1,14 @@ export * as PluginHost from "./host" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Effect, Schema } from "effect" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Credential } from "../credential" +import { EventV2 } from "../event" import { Integration } from "../integration" import { Location } from "../location" import { ModelV2 } from "../model" @@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable +const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions)) export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service + const events = yield* EventV2.Service const integration = yield* Integration.Service const location = yield* Location.Service const reference = yield* Reference.Service @@ -155,6 +159,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int callback(draft) }), }, + event: { + subscribe: () => events.live().pipe(Stream.filter(isEvent)), + }, integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 956c530176..b315d3214a 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" -import { Effect, Scope } from "effect" +import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } @@ -73,6 +73,9 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.command), reload: () => run(host.command.reload()), }, + event: { + subscribe: () => Stream.toAsyncIterable(host.event.subscribe()), + }, integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts deleted file mode 100644 index d1dbb3e37c..0000000000 --- a/packages/core/src/policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -export * as Policy from "./policy" - -import { makeLocationNode } from "./effect/app-node" -import { Context, Effect, Layer, Schema } from "effect" -import { Wildcard } from "./util/wildcard" -import { Location } from "./location" - -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: PolicyEffect, - resource: Schema.String, -}) {} - -export interface Interface { - readonly load: (statements: Info[]) => Effect.Effect - readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect - readonly hasStatements: () => boolean -} - -export class Service extends Context.Service()("@opencode/v2/Policy") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - let statements: Info[] = [] - yield* Location.Service - - return Service.of({ - load: Effect.fn("Policy.load")(function* (input) { - statements = input - }), - hasStatements: () => statements.length > 0, - evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) { - return ( - statements.findLast( - (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), - )?.effect ?? fallback - ) - }), - }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index c448eae549..511e02af87 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -3,7 +3,7 @@ export * as SkillV2 from "./skill" import { makeLocationNode } from "./effect/app-node" import path from "path" import { Context, Effect, Layer, Schema, Stream, Types } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" @@ -153,7 +153,7 @@ const layer = Layer.effect( yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) - yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( + yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => invalidate(event.data.file)), Effect.forkScoped({ startImmediately: true }), ) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e2..d32af99812 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -2,7 +2,6 @@ export * as ConfigV1 from "./config" import { Schema } from "effect" import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema" -import { ConfigExperimental } from "../../config/experimental" import { ConfigReference } from "../../config/reference" import { ConfigAgentV1 } from "./agent" import { ConfigAttachmentV1 } from "./attachment" @@ -179,9 +178,6 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), - policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ - description: "Policy statements applied to supported resources, such as provider access", - }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 2a9e1c7383..046f29c431 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) { plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), - experimental: info.experimental?.policies && { policies: info.experimental.policies }, providers: providers(info.provider), } } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 6c736cde1e..04bc006888 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" -import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" @@ -24,7 +23,7 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const catalogLayer = AppNodeBuilder.build( - LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), + LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]), [[Location.node, locationLayer]], ) const it = testEffect(catalogLayer) @@ -333,21 +332,4 @@ describe("CatalogV2", () => { expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) - - it.effect("removes providers denied by policy after loading", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const policy = yield* Policy.Service - const providerID = ProviderV2.ID.make("blocked") - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* catalog.transform((catalog) => { - catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) - }) - - expect(yield* catalog.provider.all()).toEqual([]) - expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID)).toBeUndefined() - }), - ) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 6c7f2ecc02..a8126d00f6 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,13 +1,15 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, PubSub, Schema, Stream } from "effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" @@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const it = testEffect( - AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ + AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [ [MCP.node, emptyMcpLayer], [Config.node, emptyConfigLayer], [Location.node, testLocationLayer], @@ -53,6 +55,9 @@ Review files`, }) const command = yield* CommandV2.Service + const events = yield* EventV2.Service + const update = yield* events.publish(ConfigSchema.Event.Updated, {}) + const updates = yield* PubSub.unbounded() yield* ConfigCommandPlugin.Plugin.effect( host({ command: { @@ -60,6 +65,7 @@ Review files`, transform: command.transform, reload: command.reload, }, + event: { subscribe: () => Stream.fromPubSub(updates) }, }), ).pipe( Effect.provideService( @@ -93,6 +99,15 @@ Review files`, CommandV2.Info.make({ name: "empty", template: "" }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) + + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again")) + yield* Effect.sleep("10 millis") + yield* PubSub.publish(updates, update) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* command.get("review"))?.template === "Review again") break + yield* Effect.sleep("10 millis") + } + expect((yield* command.get("review"))?.template).toBe("Review again") }), ), ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e46644abae..f3c7b20909 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -1,18 +1,20 @@ import path from "path" import fs from "fs/promises" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -26,6 +28,7 @@ function testLayer( globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, + watcher?: Layer.Layer, ) { const locationLayer = Layer.succeed( Location.Service, @@ -36,9 +39,10 @@ function testLayer( ), ), ) - return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory })], + ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -52,6 +56,52 @@ const provider = { } describe("Config", () => { + it.live("reloads external config and publishes directory updates", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const file = path.join(global, "opencode.json") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(file, JSON.stringify({ shell: "first" })) + }) + const updates = yield* PubSub.unbounded() + const watcher = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ + subscribe: () => Stream.fromPubSub(updates), + }), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2.Service + const changed = yield* events + .subscribe(ConfigSchema.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.sleep("10 millis") + + yield* PubSub.publish(updates, { + type: "update", + path: path.join(global, "commands", "review.md"), + } satisfies Watcher.Update) + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" }))) + yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update) + + expect(yield* Fiber.join(changed)).toHaveLength(1) + expect(Config.latest(yield* config.entries(), "shell")).toBe("second") + }).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher))) + }), + ), + ), + ) + it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ @@ -274,7 +324,6 @@ describe("Config", () => { const file = path.join(tmp.path, "opencode.json") const contents = JSON.stringify({ shell: "/bin/zsh", - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, providers: { local: provider }, }) yield* Effect.promise(() => fs.writeFile(file, contents)) @@ -285,11 +334,6 @@ describe("Config", () => { expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.shell).toBe("/bin/zsh") - expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ - effect: "deny", - action: "provider.use", - resource: "openai", - }) expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) }).pipe(Effect.provide(testLayer(tmp.path))) }), @@ -723,40 +767,6 @@ describe("Config", () => { ), ) - it.live("loads policy statements in reverse config order", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(global, { recursive: true }) - await fs.writeFile( - path.join(global, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, - }), - ) - await fs.writeFile( - path.join(tmp.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }, - }), - ) - }) - - return yield* Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }).pipe(Effect.provide(testLayer(tmp.path, global))) - }) - }), - ), - ) - it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 2139520468..323ba4c4ba 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" +import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), ) return Effect.provide( - AppNodeBuilder.build(Watcher.node, [ + AppNodeBuilder.build(LocationWatcher.node, [ [Config.node, configLayer], [Location.node, locationLayer], ]), @@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) { return Effect.gen(function* () { const events = yield* EventV2.Service const deferred = yield* Deferred.make() - const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( + const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => { if (!check(event.data)) return Effect.void return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) @@ -136,7 +138,27 @@ function ready(directory: string) { }) } -describeWatcher("Watcher", () => { +describeWatcher("LocationWatcher", () => { + it.live("limits file watches to the exact target", () => + withTmp((directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const watcher = yield* Watcher.Service + const target = path.join(directory, "opencode.json") + const sibling = path.join(directory, "other.json") + const update = yield* watcher + .subscribe({ path: target, type: "file" }) + .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) + yield* Effect.yieldNow + + yield* fs.writeFileString(sibling, "sibling") + yield* fs.writeFileString(target, "target") + + expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target) + }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))), + ), + ) + it.live("publishes root create, update, and delete events", () => withTmp( (directory) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 77b7207c42..0f17b0dd2b 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -51,27 +51,18 @@ describe("LocationServiceMap", () => { ), ) - it.live("isolates location state while sharing location policy with catalog", () => + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { - yield* Effect.promise(() => - fs.writeFile( - path.join(blocked.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] }, - }), - ), - ) - - const update = (directory: string) => + const update = (directory: string, providerID: ProviderV2.ID) => Effect.gen(function* () { yield* Reference.Service const catalog = yield* Catalog.Service - yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) const registry = yield* ToolRegistry.Service // Tool plugins register during the forked PluginInternal boot; wait for // every expected tool rather than relying on batch ordering. @@ -103,8 +94,11 @@ describe("LocationServiceMap", () => { ), ) - const blockedState = yield* update(blocked.path) - expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) + const blockedID = ProviderV2.ID.make("blocked-location") + const allowedID = ProviderV2.ID.make("allowed-location") + const blockedState = yield* update(blocked.path, blockedID) + expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true) + expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", @@ -119,8 +113,9 @@ describe("LocationServiceMap", () => { "websearch", "write", ]) - const allowedState = yield* update(allowed.path) - expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + const allowedState = yield* update(allowed.path, allowedID) + expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true) + expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 27f1d04061..42f2a768ec 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Schema } from "effect" +import { Effect, Exit, Fiber, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { AgentV2 } from "@opencode-ai/core/agent" +import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool/tool" @@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { + it.live("exposes public events through the plugin context", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const events = yield* EventV2.Service + const host = yield* PluginHost.make(plugins) + const received = yield* host.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 millis") + + yield* events.publish(ConfigSchema.Event.Updated, {}) + + expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated") + }), + ) + it.effect("waits for a plugin and returns immediately once active", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c5f168f265..63ac18e31e 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" -import { Effect } from "effect" +import { Effect, Stream } from "effect" type Overrides = Partial> @@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused command.transform"), reload: () => Effect.die("unused command.reload"), }, + event: overrides.event ?? { + subscribe: () => Stream.empty, + }, integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts deleted file mode 100644 index 1428c1b830..0000000000 --- a/packages/core/test/policy.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" - -const it = testEffect( - AppNodeBuilder.build(Policy.node, [ - [ - Location.node, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ], - ]), -) - -describe("Policy", () => { - it.effect("returns the caller's fallback when no statement matches", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny") - }), - ) - - it.effect("evaluates wildcard provider rules in written order", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "*", - }), - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "anthropic", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) - - it.effect("matches action and resource independently", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "company-*", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny") - expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow") - }), - ) - - it.effect("uses the last matching loaded statement", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "openai", - }), - new Policy.Info({ - effect: "deny", - action: "provider.use", - resource: "openai", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) -}) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 38ca4cfb92..7e57610afa 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -206,7 +206,7 @@ metadata: waitForSkillUpdate(), ({ deferred }) => events - .publish(FileSystemWatcher.Event.Updated, { file, event: "change" }) + .publish(FileSystem.Event.Changed, { file, event: "change" }) .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), ({ fiber }) => Fiber.interrupt(fiber), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7..312bef9f4f 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp" import { FSUtil } from "@opencode-ai/core/fs-util" import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Format } from "../format" import * as Bom from "@/util/bom" @@ -253,7 +254,7 @@ export const ApplyPatchTool = Tool.define( if (yield* format.file(edited)) { yield* Bom.syncFile(afs, edited, change.bom) } - yield* events.publish(FileSystem.Event.Edited, { file: edited }) + yield* events.publish(FileSystemV1.Event.Edited, { file: edited }) } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0..7e13b85997 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -10,6 +10,7 @@ import { LSP } from "@/lsp/lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" @@ -112,7 +113,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "add", @@ -156,7 +157,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "change", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47..7d9aa8326a 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -7,6 +7,7 @@ import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { EventV2Bridge } from "@/event-v2-bridge" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Format } from "../format" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -65,7 +66,7 @@ export const WriteTool = Tool.define( if (yield* format.file(filepath)) { yield* Bom.syncFile(fs, filepath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filepath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filepath }) yield* events.publish(Watcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index cf009812b8..1de6007b79 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -82,7 +82,7 @@ describe("v2 location HttpApi", () => { expect( Schema.decodeUnknownSync(Event)({ id: "evt_test", - type: "file.watcher.updated", + type: "filesystem.changed", location: { directory: "/tmp/project" }, data: {}, }), diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index db2f4c0ee6..219dc16581 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" @@ -16,6 +17,7 @@ export interface PluginContext { readonly aisdk: AISDKHooks readonly catalog: CatalogHooks readonly command: CommandHooks + readonly event: EventHooks readonly integration: IntegrationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index e6ea7cf0ce..49ad375d67 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,10 +1,3 @@ -import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" -import type { Stream } from "effect" +import type { EventApi } from "@opencode-ai/client/effect/api" -export type EventMap = { - [Item in SDKEvent as Item["type"]]: Item -} - -export interface Event { - subscribe(type: Type): Stream.Stream -} +export interface EventHooks extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 12b5971fef..2ebd0215b5 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 652deee9bb..5e67e44961 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" @@ -15,6 +16,7 @@ export interface PluginContext { readonly aisdk: AISDKHooks readonly catalog: CatalogHooks readonly command: CommandHooks + readonly event: EventHooks readonly integration: IntegrationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts new file mode 100644 index 0000000000..5330f70c7c --- /dev/null +++ b/packages/plugin/src/v2/promise/event.ts @@ -0,0 +1,3 @@ +import type { EventApi } from "@opencode-ai/client/promise/api" + +export interface EventHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 1050463e70..594ff7da3c 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -6,6 +6,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts new file mode 100644 index 0000000000..92b07b0e39 --- /dev/null +++ b/packages/schema/src/config.ts @@ -0,0 +1,10 @@ +export * as Config from "./config.js" + +import { ephemeral, inventory } from "./event.js" + +const Updated = ephemeral({ + type: "config.updated", + schema: {}, +}) + +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index e17ff96bc9..6b2b806f81 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -3,10 +3,11 @@ export * as EventManifest from "./event-manifest.js" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" import { Command } from "./command.js" +import { Config } from "./config.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" -import { FileSystemWatcher } from "./filesystem-watcher.js" +import { FileSystemV1 } from "./filesystem-v1.js" import { Form } from "./form.js" import { InstallationEvent } from "./installation-event.js" import { Integration } from "./integration.js" @@ -60,8 +61,8 @@ const featureDefinitions = Event.inventory( ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, ...Command.Event.Definitions, + ...Config.Event.Definitions, ...Skill.Event.Definitions, - ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, ...Shell.Event.Definitions, ...Question.Event.Definitions, @@ -97,6 +98,7 @@ export const Definitions = Event.inventory( ...TuiEvent.Definitions, ...McpEvent.Definitions, ...LegacyEvent.Definitions, + ...FileSystemV1.Event.Definitions, ...Project.Event.Definitions, ...SessionStatusEvent.Definitions, ...QuestionV1.Event.Definitions, diff --git a/packages/schema/src/filesystem-v1.ts b/packages/schema/src/filesystem-v1.ts new file mode 100644 index 0000000000..60e1a91853 --- /dev/null +++ b/packages/schema/src/filesystem-v1.ts @@ -0,0 +1 @@ +export * from "./v1/filesystem.js" diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts deleted file mode 100644 index debe0914d1..0000000000 --- a/packages/schema/src/filesystem-watcher.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as FileSystemWatcher from "./filesystem-watcher.js" - -import { Schema } from "effect" -import { ephemeral, inventory } from "./event.js" - -const Updated = ephemeral({ - type: "file.watcher.updated", - schema: { - file: Schema.String, - event: Schema.Literals(["add", "change", "unlink"]), - }, -}) -export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 3599e48c7c..3f95e97a5b 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -5,11 +5,14 @@ import { optional } from "./schema.js" import { ephemeral, inventory } from "./event.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -const Edited = ephemeral({ - type: "file.edited", - schema: { file: Schema.String }, +const Changed = ephemeral({ + type: "filesystem.changed", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, }) -export const Event = { Edited, Definitions: inventory(Edited) } +export const Event = { Changed, Definitions: inventory(Changed) } export interface Entry extends Schema.Schema.Type {} export const Entry = Schema.Struct({ diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index fb2ef17b96..1454fab1cf 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,6 @@ export { Agent } from "./agent.js" export { Command } from "./command.js" +export { Config } from "./config.js" export { Connection } from "./connection.js" export { Credential } from "./credential.js" export { Event } from "./event.js" diff --git a/packages/schema/src/v1/filesystem.ts b/packages/schema/src/v1/filesystem.ts new file mode 100644 index 0000000000..a1756fefd3 --- /dev/null +++ b/packages/schema/src/v1/filesystem.ts @@ -0,0 +1,11 @@ +export * as FileSystemV1 from "./filesystem.js" + +import { Schema } from "effect" +import { ephemeral, inventory } from "../event.js" + +const Edited = ephemeral({ + type: "file.edited", + schema: { file: Schema.String }, +}) + +export const Event = { Edited, Definitions: inventory(Edited) } diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 873e415c61..487a942d9a 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Agent, + Config, FileSystem, Form, Integration, @@ -11,6 +12,7 @@ import { Workspace, } from "../src/index.js" import { EventManifest } from "../src/event-manifest.js" +import { FileSystemV1 } from "../src/filesystem-v1.js" import { IdeEvent } from "../src/ide-event.js" import { McpEvent } from "../src/mcp-event.js" import { SessionEvent } from "../src/session-event.js" @@ -59,7 +61,9 @@ describe("public event manifest", () => { expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) - expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Config.Event.Definitions).toEqual([Config.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed]) + expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited]) expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) 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]) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c693369281..524d7ea854 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -58,15 +58,15 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventFileEdited + | EventFilesystemChanged | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventPluginAdded | EventProjectDirectoriesUpdated | EventCommandUpdated + | EventConfigUpdated | EventSkillUpdated - | EventFileWatcherUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -91,6 +91,7 @@ export type Event = | EventMcpToolsChanged | EventMcpStatusChanged | EventCommandExecuted + | EventFileEdited | EventProjectUpdated | EventSessionStatus | EventSessionIdle @@ -1280,9 +1281,10 @@ export type GlobalEvent = { } | { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } | { @@ -1339,17 +1341,16 @@ export type GlobalEvent = { } | { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } | { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } | { @@ -1576,6 +1577,13 @@ export type GlobalEvent = { messageID: string } } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } | { id: string type: "project.updated" @@ -2123,7 +2131,6 @@ export type Config = { primary_tools?: Array continue_loop_on_deny?: boolean mcp_timeout?: number - policies?: Array } } @@ -3059,15 +3066,15 @@ export type V2Event = | SessionError | InstallationUpdated | InstallationUpdateAvailable - | FileEdited + | FilesystemChanged | ReferenceUpdated | PermissionV2Asked | PermissionV2Replied | PluginAdded | ProjectDirectoriesUpdated | CommandUpdated + | ConfigUpdated | SkillUpdated - | FileWatcherUpdated | PtyCreated | PtyUpdated | PtyExited @@ -3092,6 +3099,7 @@ export type V2Event = | McpToolsChanged | McpStatusChanged | CommandExecuted + | FileEdited | ProjectUpdated | SessionStatus2 | SessionIdle @@ -4167,14 +4175,6 @@ export type ConfigV2ReferenceLocal = { hidden?: boolean } -export type PolicyEffect = "allow" | "deny" - -export type ConfigV2ExperimentalPolicy = { - action: "provider.use" - effect: PolicyEffect - resource: string -} - export type ProjectDirectory = { directory: string strategy?: string @@ -5880,16 +5880,17 @@ export type InstallationUpdateAvailable = { } } -export type FileEdited = { +export type FilesystemChanged = { id: string created: number metadata?: { [key: string]: unknown } - type: "file.edited" + type: "filesystem.changed" location?: LocationRef data: { file: string + event: "add" | "change" | "unlink" } } @@ -5981,6 +5982,19 @@ export type CommandUpdated = { } } +export type ConfigUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type SkillUpdated = { id: string created: number @@ -5994,20 +6008,6 @@ export type SkillUpdated = { } } -export type FileWatcherUpdated = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - location?: LocationRef - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyCreated = { id: string created: number @@ -6408,6 +6408,19 @@ export type CommandExecuted = { } } +export type FileEdited = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "file.edited" + location?: LocationRef + data: { + file: string + } +} + export type ProjectUpdated = { id: string created: number @@ -7209,11 +7222,12 @@ export type EventInstallationUpdateAvailable = { } } -export type EventFileEdited = { +export type EventFilesystemChanged = { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } @@ -7275,20 +7289,19 @@ export type EventCommandUpdated = { } } -export type EventSkillUpdated = { +export type EventConfigUpdated = { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } -export type EventFileWatcherUpdated = { +export type EventSkillUpdated = { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } @@ -7508,6 +7521,14 @@ export type EventCommandExecuted = { } } +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + export type EventProjectUpdated = { id: string type: "project.updated" @@ -10279,16 +10300,17 @@ export type SessionCompactionDelta2 = { } } -export type FileEdited2 = { +export type FilesystemChanged2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "file.edited" + type: "filesystem.changed" location?: LocationRef2 data: { file: string + event: "add" | "change" | "unlink" } } @@ -10384,6 +10406,21 @@ export type CommandUpdated2 = { | Array } +export type ConfigUpdated2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRef2 + data: + | { + [key: string]: unknown + } + | Array +} + export type SkillUpdated2 = { id: string created: number @@ -10399,20 +10436,6 @@ export type SkillUpdated2 = { | Array } -export type FileWatcherUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - location?: LocationRef2 - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyV2 = { id: string title: string @@ -11162,15 +11185,15 @@ export type V2EventV2 = | SessionRevertStaged2 | SessionRevertCleared2 | SessionRevertCommitted2 - | FileEdited2 + | FilesystemChanged2 | ReferenceUpdated2 | PermissionV2Asked2 | PermissionV2Replied2 | PluginAdded2 | ProjectDirectoriesUpdated2 | CommandUpdated2 + | ConfigUpdated2 | SkillUpdated2 - | FileWatcherUpdated2 | PtyCreated2 | PtyUpdated2 | PtyExited2 From 9751615651e3e776ec8e57122160e3661f3393ba Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 20:41:33 -0400 Subject: [PATCH 17/21] fix(core): reload all config plugins --- packages/core/src/config/plugin/agent.ts | 53 +++++--- packages/core/src/config/plugin/external.ts | 39 ++++-- packages/core/src/config/plugin/provider.ts | 32 +++-- packages/core/src/config/plugin/reference.ts | 67 ++++++---- packages/core/src/config/plugin/skill.ts | 18 ++- packages/core/test/config/reload.test.ts | 125 +++++++++++++++++++ 6 files changed, 264 insertions(+), 70 deletions(-) create mode 100644 packages/core/test/config/reload.test.ts diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 9c6c958025..3aebf88fbe 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent" import { define } from "../../plugin/internal" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { ConfigAgent } from "../agent" @@ -38,31 +38,34 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") + const load = Effect.fn("ConfigAgentPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } yield* ctx.agent.transform((draft) => { + const global = loaded.documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(loaded.documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { draft.update(current.id, (agent) => agent.permissions.push(...global)) } - for (const document of documents) { + for (const document of loaded.documents) { for (const [id, item] of Object.entries(document.info.agents ?? {})) { const agentID = AgentV2.ID.make(id) if (item.disabled) { @@ -95,6 +98,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.agent.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index 22f49bbf16..c67f67d481 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -2,7 +2,7 @@ export * as ConfigExternalPlugin from "./external" import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" -import { Effect, Schema } from "effect" +import { Effect, Schema, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { Config } from "../../config" @@ -42,8 +42,9 @@ export const Plugin = define({ const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service - yield* Effect.gen(function* () { - const configured: { package: string; options?: Record }[] = [] + const active = new Set() + const load = Effect.fn("ConfigExternalPlugin.load")(function* () { + const configured: { package: string; options?: Record }[] = [] for (const entry of yield* config.entries()) { if (entry.type === "document") { @@ -98,8 +99,8 @@ export const Plugin = define({ } } - for (const ref of configured) { - yield* Effect.gen(function* () { + return yield* Effect.forEach(configured, (ref) => + Effect.gen(function* () { const entrypoint = path.isAbsolute(ref.package) ? pathToFileURL(ref.package).href : (yield* npm.add(ref.package)).entrypoint @@ -108,13 +109,31 @@ export const Plugin = define({ const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - yield* ctx.plugin.add({ + return { id: plugin.id, - effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), - }) - }).pipe(Effect.ignoreCause) - } + effect: (host: Parameters[0]) => + plugin.effect({ ...host, options: ref.options ?? {} }), + } + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))), + ).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) }) + const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () { + const plugins = yield* load() + const next = new Set(plugins.map((plugin) => plugin.id)) + for (const id of active) { + if (!next.has(id)) yield* ctx.plugin.remove(id) + } + for (const plugin of plugins) yield* ctx.plugin.add(plugin) + active.clear() + for (const id of next) active.add(id) + }) + + yield* reconcile() + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reconcile()), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4992233a1f..d2d6a26029 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,7 +1,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "../../plugin/internal" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" @@ -9,14 +9,16 @@ export const Plugin = define({ id: "config-provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), - ), - ) + const loaded = { entries: yield* config.entries() } yield* ctx.integration.transform((integrations) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], + ), + ), + ) for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const integrationID = id @@ -34,8 +36,9 @@ export const Plugin = define({ } }) - const configuredDefault = Config.latest(entries, "model") yield* ctx.catalog.transform((catalog) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(loaded.entries, "model") if (configuredDefault !== undefined) { const model = ModelV2.parse(configuredDefault) catalog.model.default.set(model.providerID, model.modelID) @@ -105,5 +108,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.integration.reload()), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 1c599dab7f..d33332f92d 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference" import { define } from "../../plugin/internal" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" import { Reference } from "../../reference" @@ -16,35 +16,48 @@ export const Plugin = define({ const config = yield* Config.Service const location = yield* Location.Service const global = yield* Global.Service - const entries = new Map() - for (const doc of (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - const description = typeof entry === "string" ? undefined : entry.description - const hidden = typeof entry === "string" ? undefined : entry.hidden - entries.set( - name, - local(entry) - ? Reference.LocalSource.make({ - type: "local", - path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }) - : Reference.GitSource.make({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - ...(entry.branch === undefined ? {} : { branch: entry.branch }), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }), - ) - } - } + const loaded = { entries: yield* config.entries() } yield* ctx.reference.transform((draft) => { + const entries = new Map() + for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden + entries.set( + name, + local(entry) + ? Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }) + : Reference.GitSource.make({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }), + ) + } + } for (const [name, source] of entries) draft.add(name, source) }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.reference.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index c4b7ba95c1..ff83c53ff1 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill" import { define } from "../../plugin/internal" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" @@ -15,10 +15,10 @@ export const Plugin = define({ const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + const loaded = { entries: yield* config.entries() } yield* ctx.skill.transform((draft) => { + const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), @@ -44,5 +44,15 @@ export const Plugin = define({ ) } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.skill.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts new file mode 100644 index 0000000000..0ee2f0467e --- /dev/null +++ b/packages/core/test/config/reload.test.ts @@ -0,0 +1,125 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" +import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference" +import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" +import { EventV2 } from "@opencode-ai/core/event" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) +const document = path.join(import.meta.dir, "opencode.json") + +describe("config plugin reloads", () => { + it.live("reloads every config-backed domain", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const events = yield* EventV2.Service + const plugins = yield* PluginV2.Service + const references = yield* Reference.Service + const skills = yield* SkillV2.Service + const host = yield* PluginHost.make(plugins) + let entries: Config.Entry[] = [config("first", "First plugin")] + const service = Config.Service.of({ entries: () => Effect.sync(() => entries) }) + const setup = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Config.Service, service)) + + yield* setup(ConfigAgentPlugin.Plugin.effect(host)) + yield* setup(ConfigCommandPlugin.Plugin.effect(host)) + yield* setup(ConfigSkillPlugin.Plugin.effect(host)) + yield* setup(ConfigReferencePlugin.Plugin.effect(host)) + yield* setup(ConfigProviderPlugin.Plugin.effect(host)) + yield* setup(ConfigExternalPlugin.Plugin.effect(host)) + + expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent") + expect((yield* commands.get("first"))?.description).toBe("First command") + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(true) + expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) + expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined() + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin") + + entries = [config("second", "Second plugin")] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + Effect.gen(function* () { + return ( + (yield* agents.get(AgentV2.ID.make("first"))) === undefined && + (yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" && + (yield* commands.get("first")) === undefined && + (yield* commands.get("second"))?.description === "Second command" && + (yield* references.list()).some((reference) => reference.name === "second") && + (yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined && + (yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined && + (yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin" + ) + }), + ) + + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(false) + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), + ).toBe(true) + + entries = [config("second")] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined))) + }).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))), + ) +}) + +function config(name: string, pluginDescription?: string) { + return new Config.Document({ + type: "document", + path: document, + info: decode({ + agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, + commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, + skills: [`/skills/${name}`], + references: { [name]: `/references/${name}` }, + providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, + plugins: + pluginDescription === undefined + ? [] + : [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: pluginDescription }, + }, + ], + }), + }) +} + +function title(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 100; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for config plugin reloads") +}) From 35ed09ff37ec23c5b8dd62de41af76f8d6c0999b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 22:26:54 -0400 Subject: [PATCH 18/21] fix(tui): improve MCP error details (#35263) --- packages/tui/src/app.tsx | 4 +- packages/tui/src/component/dialog-mcp.tsx | 132 +++++++++++++++++----- 2 files changed, 108 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d5cf93c2c7..a3b2c31973 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -409,8 +409,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi else toast.show({ variant: "error", - title: "MCP server failed to connect", - message: `${server.name}: ${status.error}`, + title: `MCP server failed: ${server.name}`, + message: "Open MCPs to view details.", }) } }) diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 004e4a6e15..003c207be0 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,12 +1,17 @@ import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" -import { createStore } from "solid-js/store" import { useData } from "../context/data" import { pipe, sortBy } from "remeda" -import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" +import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useTheme, type Theme } from "../context/theme" -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import type { McpServer } from "@opencode-ai/sdk/v2" +import { useClipboard } from "../context/clipboard" +import { useToast } from "../ui/toast" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTuiConfig } from "../config" +import { getScrollAcceleration } from "../util/scroll" +import { useBindings } from "../keymap" // Sort by how much attention a server needs: auth prompts first, then failures, // then healthy servers, and intentionally-off servers last. @@ -31,9 +36,8 @@ export function DialogMcp() { const data = useData() const dialog = useDialog() const { theme } = useTheme() - const [expanded, setExpanded] = createStore>({}) const [focused, setFocused] = createSignal() - const [, setRef] = createSignal>() + const [detail, setDetail] = createSignal() onMount(() => { dialog.setSize("large") @@ -66,9 +70,6 @@ export function DialogMcp() { {meta.icon} {meta.label} ), - details: meta.error && expanded[server.name] ? [meta.error] : undefined, - detailsColor: theme.error, - detailsWrap: true, } }), ) @@ -79,24 +80,103 @@ export function DialogMcp() { return server ? statusMeta(server.status, theme).error : undefined }) + const open = (name: string | undefined) => { + const server = servers().find((entry) => entry.name === name) + if (!server || !statusMeta(server.status, theme).error) return + setDetail(server) + } + return ( - setFocused(option.value as string)} - onSelect={(option) => { - const name = option.value as string - const server = servers().find((entry) => entry.name === name) - if (!server || !statusMeta(server.status, theme).error) return - setExpanded(name, (open) => !open) - }} - footer={ - - enter to {expanded[focused()!] ? "hide" : "view"} error - - } - /> + + setFocused(option.value as string)} + onSelect={(option) => open(option.value as string)} + footer={ + + enter to view error + + } + /> + } + > + {(server) => setDetail()} />} + + + ) +} + +function DialogMcpError(props: { server: McpServer; onBack: () => void }) { + const dialog = useDialog() + const clipboard = useClipboard() + const toast = useToast() + const { theme } = useTheme() + const dimensions = useTerminalDimensions() + const tuiConfig = useTuiConfig() + const [copied, setCopied] = createSignal(false) + const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error" + const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) + let scroll: ScrollBoxRenderable | undefined + + onMount(() => dialog.setSize("large")) + + const copy = () => { + if (!clipboard.write) return + void clipboard + .write(error()) + .then(() => setCopied(true)) + .catch(toast.error) + } + + useBindings(() => ({ + bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }], + })) + + useKeyboard((event) => { + if (event.name === "c") return copy() + if (event.name === "up") return scroll?.scrollBy(-1) + if (event.name === "down") return scroll?.scrollBy(1) + if (event.name === "pageup") return scroll?.scrollBy(-height()) + if (event.name === "pagedown") return scroll?.scrollBy(height()) + if (event.name === "home") return scroll?.scrollTo(0) + if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) + }) + + return ( + + + + MCP / {props.server.name} + + + esc back + + + ✗ Failed + + (scroll = element)} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={getScrollAcceleration(tuiConfig)} + > + + {error()} + + + + + ↑↓ scroll + + {copied() ? "✓ copied" : "c copy details"} + + + ) } From e2faeb84e528514c4ff4c5848deff0541568f3c2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 3 Jul 2026 22:37:42 -0400 Subject: [PATCH 19/21] fix(core): tolerate minimal FSWatcher typings (#35264) --- packages/core/src/filesystem/watcher.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 2febe93b76..9a72903d84 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -89,9 +89,11 @@ const layer = Layer.effect( type: "update", } satisfies Update) }) - subscription.on("error", (error) => - Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })), - ) + if ("on" in subscription && typeof subscription.on === "function") { + subscription.on("error", (error: unknown) => + Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })), + ) + } return { unsubscribe: () => Promise.resolve(subscription.close()) } }) : subscribeDirectory(native, backend, directory, ignore, pubsub) From afe3ebbc35731131893b98893e8fadfbcf4562dd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 23:12:06 -0400 Subject: [PATCH 20/21] fix(core): bust external plugin import cache --- packages/core/src/config/plugin/external.ts | 19 +++++- packages/core/test/config/plugin.test.ts | 73 +++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index c67f67d481..29a63396a9 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -3,6 +3,7 @@ export * as ConfigExternalPlugin from "./external" import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" import { Effect, Schema, Stream } from "effect" +import { createRequire } from "node:module" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { Config } from "../../config" @@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({ module: Schema.optional(Schema.String), }) +let importGeneration = 0 +const moduleCache = createRequire(import.meta.url).cache + export const Plugin = define({ id: "config-plugin", effect: Effect.fn(function* (ctx) { @@ -106,7 +110,7 @@ export const Plugin = define({ : (yield* npm.add(ref.package)).entrypoint if (!entrypoint) return yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint }) - const mod = yield* Effect.promise(() => import(entrypoint)) + const mod = yield* Effect.promise(() => import(cacheBust(entrypoint))) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) return { @@ -114,7 +118,11 @@ export const Plugin = define({ effect: (host: Parameters[0]) => plugin.effect({ ...host, options: ref.options ?? {} }), } - }).pipe(Effect.catchCause(() => Effect.succeed(undefined))), + }).pipe( + Effect.catchCause((cause) => + Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)), + ), + ), ).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) }) const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () { @@ -137,6 +145,13 @@ export const Plugin = define({ }), }) +function cacheBust(entrypoint: string) { + const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint) + if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)] + url.searchParams.set("opencode-reload", String(++importGeneration)) + return url.href +} + const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b72a62df8a..8a28077c3e 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -1,15 +1,19 @@ +import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" @@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => { }) }), ) + + it.live("reloads changed plugin source from the same entrypoint", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const events = yield* EventV2.Service + const fsUtil = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + const plugin = path.join(tmp.path, "plugin.ts") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ plugins: [plugin] }), + }), + ]), + }) + + yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source"))) + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fsUtil), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService(Config.Service, config), + ) + expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source") + + yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source"))) + yield* events.publish(ConfigSchema.Event.Updated, {}) + expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true) + }), + ), + ), + ) }) const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { @@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: } return yield* Effect.die(`Timed out waiting for agent ${id}`) }) + +const waitForAgentDescription = Effect.fnUntraced(function* ( + agents: AgentV2.Interface, + id: string, + description: string, +) { + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true + yield* Effect.sleep("10 millis") + } + return false +}) + +function pluginSource(description: string) { + return `export default { + id: "source-hot-reload", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("hot-reload", (agent) => { + agent.description = ${JSON.stringify(description)} + agent.mode = "subagent" + }) + }) + }, +}` +} From 3baaabede864451a8152ead3654b87ccea28866f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:00:10 -0500 Subject: [PATCH 21/21] fix(core): resolve mcp header env placeholders (#35236) --- packages/core/src/config.ts | 4 +- packages/core/src/config/variable.ts | 85 ++++++++++++++++++++++++ packages/core/test/config/config.test.ts | 66 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/config/variable.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9e829fb740..48c380aef3 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -23,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" +import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" @@ -148,9 +149,10 @@ const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) if (!text) return + const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) const errors: ParseError[] = [] - const input: unknown = parse(text, errors, { allowTrailingComma: true }) + const input: unknown = parse(substituted, errors, { allowTrailingComma: true }) if (errors.length) return const info = Option.getOrUndefined( diff --git a/packages/core/src/config/variable.ts b/packages/core/src/config/variable.ts new file mode 100644 index 0000000000..3bb1d22dd3 --- /dev/null +++ b/packages/core/src/config/variable.ts @@ -0,0 +1,85 @@ +export * as ConfigVariable from "./variable" + +import os from "os" +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "../fs-util" +import { InvalidError } from "../v1/config/error" + +type ParseSource = + | { + type: "path" + path: string + } + | { + type: "virtual" + source: string + dir: string + } + +type SubstituteInput = ParseSource & { + text: string + missing?: "error" | "empty" + env?: Record +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) { + const text = input.text.replace( + /\{env:([^}]+)\}/g, + (_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "", + ) + if (!text.includes("{file:")) return text + return yield* substituteFiles(input, text) +}) + +const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) { + const fs = yield* FSUtil.Service + const configDir = input.type === "path" ? path.dirname(input.path) : input.dir + const configSource = input.type === "path" ? input.path : input.source + const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + let out = "" + let cursor = 0 + + for (const match of matches) { + const token = match[0] + const index = match.index + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath + const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath) + const fileContent = yield* fs.readFileString(resolvedPath).pipe( + Effect.catch((error) => { + if (input.missing === "empty") return Effect.succeed("") + + const message = `bad file reference: "${token}"` + return Effect.fail( + new InvalidError( + { + path: configSource, + message: + error._tag === "PlatformError" && error.reason._tag === "NotFound" + ? `${message} ${resolvedPath} does not exist` + : message, + }, + { cause: error }, + ), + ) + }), + ) + + out += JSON.stringify(fileContent.trim()).slice(1, -1) + cursor = index + token.length + } + + return out + text.slice(cursor) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index f3c7b20909..edf18d5add 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -292,6 +292,72 @@ describe("Config", () => { ), ) + it.live("substitutes environment variables and relative file contents", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + token: process.env.OPENCODE_TEST_MCP_TOKEN, + missing: process.env.OPENCODE_TEST_MISSING, + } + process.env.OPENCODE_TEST_MCP_TOKEN = "secret" + delete process.env.OPENCODE_TEST_MISSING + return previous + }), + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Ignored reference: {file:missing.txt} + "username": "user-{env:OPENCODE_TEST_MISSING}", + "mcp": { + "servers": { + "remote": { + "type": "remote", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}", + "X-Token": "{file:token.txt}" + } + } + } + } + }`, + ), + ]), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const document = (yield* config.entries()).find((entry) => entry.type === "document") + expect(document?.info.username).toBe("user-") + const remote = document?.info.mcp?.servers?.remote + expect(remote?.type).toBe("remote") + if (remote?.type !== "remote") return + expect(remote.headers).toEqual({ + Authorization: "Bearer secret", + "X-Token": 'file\n"token"', + }) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + (previous) => + Effect.sync(() => { + if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN + else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token + if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING + else process.env.OPENCODE_TEST_MISSING = previous.missing + }), + ), + ) + it.live("does not load legacy config.json files", () => Effect.acquireRelease( Effect.promise(() => tmpdir()),