From 7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 21 Oct 2026 19:39:47 +0900 Subject: fix(chat): bound live subscriptions and preserve unconfirmed input --- .dispatch/transport-contract.reference.md | 1553 +++++++++++++++++---------- backend-handoff.md | 29 + src/adapters/ws/index.ts | 14 +- src/adapters/ws/logic.ts | 26 +- src/app/App.test.ts | 1 + src/app/chat-registry.svelte.ts | 59 +- src/app/open-conversations.svelte.ts | 16 +- src/app/store.test.ts | 87 +- src/app/subscription-budget.test.ts | 20 + src/app/subscription-budget.ts | 15 + src/app/surface-protocol.svelte.ts | 30 +- src/app/surface-protocol.test.ts | 31 + src/app/views/ChatPane.svelte | 5 +- src/app/ws-handlers.ts | 37 +- src/core/wire/conformance.test.ts | 8 + src/core/wire/conformance.ts | 4 + src/features/chat/adapter/pending-inputs.ts | 49 + src/features/chat/delivery.test.ts | 146 +++ src/features/chat/index.ts | 3 + src/features/chat/pending-inputs.ts | 12 + src/features/chat/store.svelte.ts | 104 +- src/features/chat/store.test.ts | 1 + src/features/chat/ui/PendingInputs.svelte | 34 + 23 files changed, 1672 insertions(+), 612 deletions(-) create mode 100644 src/app/subscription-budget.test.ts create mode 100644 src/app/subscription-budget.ts create mode 100644 src/features/chat/adapter/pending-inputs.ts create mode 100644 src/features/chat/delivery.test.ts create mode 100644 src/features/chat/pending-inputs.ts create mode 100644 src/features/chat/ui/PendingInputs.svelte diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index e798fa7..e0ef475 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,161 +1,26 @@ -# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules) - -> **2026-10-20 review 3.5:** history replacements preserve monotonic seqs (retained history need not start at 1). HTTP history and WS compaction carry required `historyEpoch`; unknown cached epochs must be reloaded. Consumed by review 3.6. -> -> MIRRORS the backend's `@dispatch/transport-contract` package source so headless FE agents can read -> the transport types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a -> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for -> READING only. -> -> **2026-10-14 delta (account-usage — ADDITIVE, `0.25.0` → `0.26.0`):** `GET /usage` + `GET /usage/:provider` + -> `PUT /usage/:provider/settings` (Hyper daily credits) and the `AccountUsage*` types (verbatim at the end of this file). -> -> **Orchestrator:** SNAPSHOT of `transport-contract@0.24.0` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever -> it changes. -> -> **2026-09-04 delta (tiled layout plan 02 — ADDITIVE, no version bump; 2026-09-06: activation is PER WORKSPACE):** the -> layout-provider contract ships. NEW WS message: `LayoutChangedMessage` (`{ type: "layout"; workspaceId: string; tree: -> LayoutTree }` — server→client broadcast after every successful layout write; the FULL tree, decision 7; a global -> fan-out and CLIENTS FILTER by their active workspace). `LayoutTree` is re-exported from `@dispatch/layout-contract`. -> NEW HTTP routes (owned by the workspace's ACTIVE layout plugin — multiple layout plugins coexist loaded, and the -> WORKSPACE's per-workspace enablement state (the plugins modal) picks the ONE that serves; while the workspace enables -> NO layout plugin every route 404s, the frontend's no-provider signal): `GET /workspaces/:id/layout` (`{ tree }` — -> read seeds a fresh workspace), `PUT /workspaces/:id/layout` (body `{ tree }` → 200 `{ tree }` / 400 `{ error }` -> structural-invalidity), `DELETE /workspaces/:id/layout` (re-seed on next read; returns the fresh seed). The FE -> consumes these via `features/layout` (`adapter/http.ts` + `store.svelte.ts`); the `layout` message is parsed in -> `adapters/ws/logic.ts` and dispatched to `store.layout.onServerTree` (non-active workspaces ignored); the workspace -> plugins modal's toggles re-probe via `store.layout.onWorkspacePluginsChanged` (the per-workspace activation -> trigger). -> -> **2026-09-03 delta (plugin system — plans 05/06, NO version bump verified against the worktree):** the -> plugin-manager + per-workspace enablement contract ships. NEW WS messages: `WorkspaceSetMessage` -> (`{ type: "workspace.set"; workspaceId }` — client→server; the server records the connection's workspace -> and re-pushes the workspace-filtered surface catalog on receipt + on every broadcast) and -> `ExtensionsChangedMessage` (`{ type: "extensions-changed"; action: "load"|"unload"|"reload"|"scan"|"forget"; -> pluginId? }` — server→client broadcast after every plugin-manager mutation; clients refetch `GET /plugins` -> while the server re-pushes the catalog). NEW HTTP routes: `GET /plugins` (`PluginListResponse` — -> `loaded`/`disabledWithReason`/`discovered`/`missing` rows), `POST /plugins/scan` (`PluginScanResponse`), -> `POST /plugins/:id/load|unload|reload|forget` (`PluginOpResponse` = `PluginOpSuccess | PluginOpBlocked`, -> 404/409 with `PluginBlockReason`: `core-locked | last-provider | cascade-needs-force | not-loaded | -> not-found | already-loaded | engine-refused`), and plan 06 §6.6's `GET /workspaces/:id/plugins` -> (`WorkspacePluginsResponse` — read-through materialization), `PUT /workspaces/:id/plugins/:pluginId` -> (`{ state: "enabled"|"disabled"|"unset" }` → 409 `core-locked|unloaded`), and -> `GET /workspaces/:id/plugins/providers-status` (`{ workspaceId, enabledProviderCount }` — the §6.5 -> zero-providers guardrail signal). `GET /models?workspaceId=` is now workspace-aware (no-param = global). -> NOTE: the body below does NOT yet contain these sections — read the types from -> `node_modules/@dispatch/transport-contract` (or the backend worktree) until this mirror is fully -> regenerated. The FE (plan 07) consumes them via `features/plugins`. -> -> **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** the `/named-agents` HTTP surface + -> conversation snapshot wiring ships. New types: `NamedAgentsResponse` (`{ namedAgents: NamedAgent[] }`, -> `GET /named-agents`), `NamedAgentResponse` (`{ namedAgent: NamedAgent }`, `POST`/`PUT`), and -> `DeleteNamedAgentResponse` (`{ id }`), with `CreateNamedAgentRequest` / `UpdateNamedAgentRequest` -> (`{ name, model }`). Snapshot semantics (plan §7): `ChatRequest.namedAgent?` (persist on the -> conversation at creation), `ModelResponse.namedAgent?` (the persisted `NamedAgentSelectionSnapshot`), -> and `SetModelRequest.namedAgent?` (persist/clear the snapshot with the model — omit for a raw -> selection, pass `null` notion + omit `namedAgent` to clear). The FE consumes these via -> `features/named-agents` + the app store's CRUD/`selectModel` methods (the local `logic/types.ts` -> re-exports `NamedAgent`/`NamedAgentSelectionSnapshot` from `@dispatch/wire` and these request/response -> types from this package). -> -> **2026-06-29 update (cancel-queued-message — ADDITIVE, 0.23.0 → 0.24.0):** a per-message -> **cancel** for the steering message queue ships. While a turn is GENERATING and a user -> message is queued (awaiting steering delivery), the client can cancel a single queued -> message by id so it never runs. New `WsClientMessage` member `ChatQueueCancelMessage` -> (`{ type: "chat.queue.cancel"; conversationId; messageId }` — fire-and-forget, idempotent; -> success confirmed by the `message-queue` surface updating, failure as `chat.error`). New -> HTTP path `DELETE /conversations/:id/queue/:messageId` → `QueueCancelResponse` -> (`{ conversationId; cancelled: boolean; queue }`). The cancel is scoped per conversation; -> cancelling a drained/already-cancelled/unknown message is a silent no-op. `@dispatch/wire` -> is unchanged (`QueuedMessage.id` is the cancel target). -> -> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains -> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry` -> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is -> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced -> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when -> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` — -> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user -> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown -> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when -> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded; -> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer, -> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded. -> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream -> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3. -> -> **2026-06-26 delta (provider concurrency — `transport-contract@0.23.0` bump):** adds the -> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`), -> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`), -> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The -> `concurrency` extension tracks/limits in-flight token-generating requests per provider with -> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and -> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j. -> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface. -> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]` -> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/ -> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the -> model natively accepts images; absent → the server's vision handoff transcribes images to text before the -> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here). -> -> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image` -> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it -> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the -> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED -> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to -> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image -> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: ` text -> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global -> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`), -> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable -> compaction; `compactionModel?` `/` or null = auto). See `backend-handoff.md` §2j. -> -> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now -> compact relative HTTP paths (`/images//.png`) served by the new -> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data -> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`) -> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in -> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the -> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j. -> -> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `transport-contract@0.22.0`, NO version bump):** adds the -> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), -> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`), -> `SetConversationComputerRequest` + `ConversationComputerResponse` -> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` -> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/ -> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so -> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See -> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave — -> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.) -> -> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds -> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint -> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns -> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`, -> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`, -> and optional `configSource`. Also adds the previously-missing `configSource` field to -> `LspServerInfo`. See `frontend-mcp-status-handoff.md`. -> -> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds -> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and -> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and -> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation -> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`, -> and dynamic `file:`; conditional blocks use `[if]`, `[else]`, `[endif]`. See -> `frontend-system-prompt-handoff.md`. +# `@dispatch/transport-contract` — in-repo reference +Mirrored from the sibling backend on 2026-10-21 for `feature/subscription-delivery-fix`. +Package version remains 0.26.0; local `file:` dependency unchanged. + +New additive messages: `chat.accepted` after opening-message storage or queue insertion, +`chat.subscription-rejected` for a nonfatal watcher limit, and `requestId` on queue requests +and chat errors. Correlation tokens are not idempotency keys; clients must not automatically +retry uncertain sends. The server retains the 256 combined subscription bound. + +```ts /** * Transport contract — the typed description of Dispatch's client–server API * (HTTP + WebSocket). * - * This package is types-only (zero runtime). It is the single shared surface + * This package defines types plus pure conversation-ID boundary validation. + * That deliberate runtime exception is not a shared codec. It is the shared surface * every client imports to know how to talk to the backend — the CLI, the web * frontend (in its own repo), any third-party client — and the transport-http / * transport-ws servers import to know what they must accept and emit. * * Each side owns its OWN (de)serialization: there is deliberately no shared - * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, + * wire parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, * not the codec. The streaming response payload is the kernel's `AgentEvent` * union, re-exported here so a client has one import for the whole wire. * @@ -166,39 +31,47 @@ * is unchanged. */ +import type { LayoutTree } from "@dispatch/layout-contract"; +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { - AgentEvent, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - ImageInput, - QueuedMessage, - ReasoningEffort, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageInput, + NamedAgent, + NamedAgentSelectionSnapshot, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; +export type { LayoutTree } from "@dispatch/layout-contract"; export type { - AgentEvent, - CompactionResult, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - ImageChunk, - ImageInput, - QueuedMessage, - ReasoningEffort, - StepMetrics, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + CompactionResult, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageChunk, + ImageInput, + NamedAgent, + NamedAgentSelectionSnapshot, + QueuedMessage, + ReasoningEffort, + StepMetrics, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; +export { isValidConversationId, parseConversationId } from "./conversation-id.js"; /** * Request body for `POST /chat` (sent as JSON). @@ -208,68 +81,99 @@ export type { * response header (useful when `conversationId` was omitted). */ export interface ChatRequest { - /** - * The conversation to continue. Omit to start a fresh conversation — the - * server mints an id and returns it via the `X-Conversation-Id` header. - */ - readonly conversationId?: string; - - /** The user's message text for this turn. */ - readonly message: string; - - /** - * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's - * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` - * URL. The server converts these to `image` chunks on the persisted user - * message. For a VISION-capable model (e.g. kimi), the images are passed - * through to the provider natively. For a NON-vision model (e.g. glm-5.2), - * the server's vision handoff transcribes each image to a text description - * (via a vision-capable model) and feeds that text instead — so a text-only - * model can still reason about the image's contents. Optional — omit for a - * text-only turn (backward compatible). Validation: non-array `images` → - * 400; an image without `url` → 400; empty `url` → 400. An empty array is - * accepted and treated as absent. - */ - readonly images?: readonly ImageInput[]; - - /** - * The model to use, as a model name in `/` form — one - * of the exact strings returned by `GET /models`. Omit to use the server's - * default credential + model. - */ - readonly model?: string; - - /** - * Working directory for this turn's tool execution. Defaults server-side when - * omitted. Forwarded to tools for path resolution; never part of the model - * prompt (so it does not affect prompt caching). - */ - readonly cwd?: string; - - /** - * Reasoning-effort override for THIS turn only (does not persist). When - * omitted, the server resolves the conversation's persisted value, falling - * back to `"high"`. Must be one of the `ReasoningEffort` levels; an - * unrecognized value → HTTP 400 `{ error }`. - */ - readonly reasoningEffort?: ReasoningEffort; - - /** - * The workspace to assign this conversation to. Omit for `"default"`. - * If the workspace doesn't exist yet, it is auto-created (title = id, - * defaultCwd = null). - */ - readonly workspaceId?: string; - - /** - * The computer to run this turn's tools on — an SSH config `Host` alias - * (one of the `alias` values returned by `GET /computers`). Omit to inherit - * the resolved chain: per-conversation `computerId` → the workspace's - * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is - * a per-turn tool-execution target forwarded to tools and never part of the - * model prompt (so it does not affect prompt caching). Mirrors `cwd`. - */ - readonly computerId?: string; + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + * When supplied, must match `^[A-Za-z0-9_-]{1,128}$` (UUIDs and legacy IDs). + * Empty, null, and non-string values are rejected, not treated as absent. + */ + readonly conversationId?: string; + + /** The user's message text for this turn. */ + readonly message: string; + + /** + * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's + * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` + * URL. The server converts these to `image` chunks on the persisted user + * message. For a VISION-capable model (e.g. kimi), the images are passed + * through to the provider natively. For a NON-vision model (e.g. glm-5.2), + * the server's vision handoff transcribes each image to a text description + * (via a vision-capable model) and feeds that text instead — so a text-only + * model can still reason about the image's contents. Optional — omit for a + * text-only turn (backward compatible). + */ + readonly images?: readonly ImageInput[]; + + /** + * The model to use, as a model name in `/` form — one + * of the exact strings returned by `GET /models`. Omit to use the server's + * default credential + model. + */ + readonly model?: string; + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ + readonly cwd?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; + + /** + * Reasoning-effort override for THIS turn only (does not persist). When + * omitted, the server resolves the conversation's persisted value, falling + * back to `"high"`. Must be one of the `ReasoningEffort` levels; an + * unrecognized value → HTTP 400 `{ error }`. + */ + readonly reasoningEffort?: ReasoningEffort; + + /** + * The workspace to assign this conversation to. Omit for `"default"`. + * If the workspace doesn't exist yet, it is auto-created (title = id, + * defaultCwd = null). + */ + readonly workspaceId?: string; + + /** + * A human-readable title for the conversation tab — persisted at creation + * time, after the new-conversation workspace setup resolves (so workspace + * assignment and first-turn system-prompt construction are not skipped) and + * before the first message append (so the append's auto-derived title does + * not overwrite it). The tab shows it instead of the default derived from + * the first message (`"Untitled"` until the first append). Omit to keep the + * auto-derived title. When present, the value is trimmed server-side; a + * whitespace-only value is treated as absent (auto-derive). A non-string + * value → HTTP 400 `{ error }`. + * + * Backward compatible — clients that omit it are unaffected. Mirrors the + * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the + * turn (no second round-trip from the client). + */ + readonly title?: string; + + /** + * Named Agent selection snapshot to persist on the conversation at creation. + * + * When the client starts a conversation via the synthetic **Named Agents** + * provider, it resolves the selected Named Agent to its raw `model` and sends + * that **plus** this snapshot so the conversation freezes how it was selected + * (snapshot semantics — see `NamedAgentSelectionSnapshot` in `@dispatch/wire`). + * The server persists it alongside the model; later edits/deletes of the Named + * Agent do not change this conversation's selection. Omit for a raw-model-only + * conversation (backward compatible). Ignored/overwritten by a subsequent + * `PUT /conversations/:id/model` that carries its own snapshot. + */ + readonly namedAgent?: NamedAgentSelectionSnapshot; } /** @@ -282,21 +186,63 @@ export interface ChatRequest { * read `models` are unaffected. */ export interface ModelsResponse { - readonly models: readonly string[]; - readonly modelInfo?: Readonly>; + readonly models: readonly string[]; + readonly modelInfo?: Readonly>; } /** Per-model metadata returned alongside the model catalog. */ export interface ModelMetadata { - readonly contextWindow?: number; - /** - * Whether this model can natively accept image input (vision/multimodal). - * When `true`, image chunks in a user message are passed through to the - * provider. When `false`/absent, the server's vision handoff transcribes - * images to text before the model sees them. A client may use this to show - * a vision badge in the model picker. Optional — absent when unknown. - */ - readonly vision?: boolean; + readonly contextWindow?: number; + /** + * Whether this model can natively accept image input (vision/multimodal). + * When `true`, image chunks in a user message are passed through to the + * provider. When `false`/absent, the server's vision handoff transcribes + * images to text before the model sees them. A client may use this to show a + * vision badge in the model picker. Optional — absent when unknown. + */ + readonly vision?: boolean; +} + +// ─── Named Agents ──────────────────────────────────────────────────────────── + +/** + * Response body for `GET /named-agents` — the list of configured Named Agents. + * + * Each record is a user-curated model preset (`id`, friendly `name`, canonical + * `/`). This is the local configuration that drives the + * "Named Agents" synthetic provider in the frontend model selector and the + * `list_agent_models`/`summon_agent` agent tools — NOT the raw model catalog + * (which `GET /models` continues to serve unchanged). + */ +export interface NamedAgentsResponse { + readonly namedAgents: readonly NamedAgent[]; +} + +/** Response body for `POST`/`PUT /named-agents(/:id)` — the canonical saved record. */ +export interface NamedAgentResponse { + readonly namedAgent: NamedAgent; +} + +/** Request body for `POST /named-agents` — create a Named Agent. */ +export interface CreateNamedAgentRequest { + /** User-chosen friendly name (trimmed; unique case-insensitively). */ + readonly name: string; + /** Canonical `/` identifier. */ + readonly model: string; +} + +/** Request body for `PUT /named-agents/:id` — full update of a Named Agent. */ +export interface UpdateNamedAgentRequest { + readonly name: string; + readonly model: string; +} + +/** + * Response body for `DELETE /named-agents/:id` — confirms removal of the record + * with the stable id. + */ +export interface DeleteNamedAgentResponse { + readonly id: string; } /** @@ -373,15 +319,15 @@ export interface ConversationHistoryResponse { * absent until then. */ export interface ConversationMetricsResponse { - readonly turns: readonly TurnMetrics[]; + readonly turns: readonly TurnMetrics[]; } export interface ConversationStatusResponse { - readonly conversationId: string; - /** True if the orchestrator has an in-memory active turn for this conversation. */ - readonly isActive: boolean; - /** The persisted lifecycle status from the conversation store. */ - readonly status: ConversationStatus; + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; } /** The aggregation window for `GET /metrics/throughput`. */ @@ -395,16 +341,16 @@ export type ThroughputPeriod = "day" | "week" | "month"; * waits). */ export interface ThroughputModelStat { - /** The model name in `/` form (as selected). */ - readonly model: string; - /** Token-weighted average tokens/second over the period. */ - readonly tokensPerSecond: number; - /** Total output tokens generated across the period's turns. */ - readonly totalOutputTokens: number; - /** Total pure generation time across the period's turns, in milliseconds. */ - readonly totalGenMs: number; - /** Number of turns that contributed. */ - readonly turns: number; + /** The model name in `/` form (as selected). */ + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + /** Total output tokens generated across the period's turns. */ + readonly totalOutputTokens: number; + /** Total pure generation time across the period's turns, in milliseconds. */ + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; } /** @@ -418,21 +364,21 @@ export interface ThroughputModelStat { * `tokensPerSecond` descending. */ export interface ThroughputResponse { - readonly period: ThroughputPeriod; - readonly date: string; - /** Inclusive start of the window, epoch-ms. */ - readonly start: number; - /** Exclusive end of the window, epoch-ms. */ - readonly end: number; - readonly models: readonly ThroughputModelStat[]; + readonly period: ThroughputPeriod; + readonly date: string; + /** Inclusive start of the window, epoch-ms. */ + readonly start: number; + /** Exclusive end of the window, epoch-ms. */ + readonly end: number; + readonly models: readonly ThroughputModelStat[]; } // ─── Per-conversation working directory (cwd) ───────────────────────────────── /** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ export interface CwdResponse { - readonly conversationId: string; - readonly cwd: string | null; + readonly conversationId: string; + readonly cwd: string | null; } /** @@ -446,8 +392,8 @@ export interface CwdResponse { * `"default"` if none). */ export interface SetCwdRequest { - readonly cwd: string; - readonly workspaceId?: string; + readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -458,8 +404,8 @@ export interface SetCwdRequest { * `"high"`). */ export interface ReasoningEffortResponse { - readonly conversationId: string; - readonly reasoningEffort: ReasoningEffort | null; + readonly conversationId: string; + readonly reasoningEffort: ReasoningEffort | null; } /** @@ -469,19 +415,27 @@ export interface ReasoningEffortResponse { * unrecognized level → HTTP 400 `{ error }`. */ export interface SetReasoningEffortRequest { - readonly reasoningEffort: ReasoningEffort; + readonly reasoningEffort: ReasoningEffort; } -// ─── Per-conversation model persistence ─────────────────────────────────────── +// ─── Per-conversation model ────────────────────────────────────────────────── /** * Response of `GET /conversations/:id/model`. `model` is the persisted model * name in `/` form, or null when never set (the server * then resolves turns using the default provider + model). + * + * `namedAgent` is the Named Agent selection snapshot recorded when the + * conversation was started/selected via a Named Agent. Present only for + * conversations that were created through a Named Agent (snapshot semantics: it + * freezes the friendly id/name + resolved model at selection time, so later + * edits to the Named Agent do not change this conversation's selection). A + * conversation holding only a raw model carries no `namedAgent`. */ export interface ModelResponse { - readonly conversationId: string; - readonly model: string | null; + readonly conversationId: string; + readonly model: string | null; + readonly namedAgent?: NamedAgentSelectionSnapshot; } /** @@ -490,9 +444,16 @@ export interface ModelResponse { * `ChatRequest.model` override). Pass `null` to clear the persisted selection. * An unrecognized model name is not validated here (the provider resolves it * at turn time; an unknown model → turn error, not a 400). + * + * `namedAgent` (optional) is a Named Agent selection snapshot to persist with + * the model — sent when the client sets this conversation's model via the + * synthetic **Named Agents** provider so the conversation freezes the friendly + * selection. When `model` is `null` (clear), the persisted snapshot is also + * cleared. Omit entirely for a raw-model selection (backward compatible). */ export interface SetModelRequest { - readonly model: string | null; + readonly model: string | null; + readonly namedAgent?: NamedAgentSelectionSnapshot; } // ─── Conversation close (explicit tab close) ────────────────────────────────── @@ -512,9 +473,9 @@ export interface SetModelRequest { * `abortedTurn: false`. */ export interface CloseConversationResponse { - readonly conversationId: string; - /** True when an in-flight turn existed and was aborted by this close. */ - readonly abortedTurn: boolean; + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; } // ─── System prompt template ─────────────────────────────────────────────────── @@ -529,8 +490,8 @@ export interface CloseConversationResponse { * and reused on all subsequent turns (cache-safe — no per-turn reconstruction). */ export interface SystemPromptTemplateResponse { - /** The template text (may be empty — then no system prompt is sent). */ - readonly template: string; + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; } /** @@ -541,7 +502,7 @@ export interface SystemPromptTemplateResponse { * conversations use the new template on their first turn. */ export interface SetSystemPromptTemplateRequest { - readonly template: string; + readonly template: string; } /** @@ -550,43 +511,39 @@ export interface SetSystemPromptTemplateRequest { * selector buttons. */ export interface SystemPromptVariable { - /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ - readonly type: string; - /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ - readonly name: string; - /** Human-readable description of what the variable resolves to. */ - readonly description: string; - /** - * When `true`, any name is valid for this type (e.g. `file:` accepts - * any file path). The frontend should allow free-text input for the name. - */ - readonly dynamic?: boolean; + /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ + readonly type: string; + /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ + readonly name: string; + /** Human-readable description of what the variable resolves to. */ + readonly description: string; + /** + * When `true`, any name is valid for this type (e.g. `file:` accepts + * any file path). The frontend should allow free-text input for the name. + */ + readonly dynamic?: boolean; } /** Response of `GET /system-prompt/variables`. */ export interface SystemPromptVariablesResponse { - readonly variables: readonly SystemPromptVariable[]; + readonly variables: readonly SystemPromptVariable[]; } -// ─── Vision settings (global) ─────────────────────────────────────────────── +// ─── Vision settings (global) ────────────────────────────────────────────────── /** * Response of `GET /settings/vision` — the global vision configuration shared * across all conversations and vision models. */ export interface VisionSettingsResponse { - /** Max native images per turn (default 10); 0 disables image compaction. */ - readonly imageLimit: number; - /** Which model transcribes old images (null = auto-select a vision model). */ - readonly compactionModel: string | null; + readonly imageLimit: number; + readonly compactionModel: string | null; } /** Body of `PUT /settings/vision` — a partial update. */ export interface SetVisionSettingsRequest { - /** Non-negative integer (0 = disable compaction). */ - readonly imageLimit?: number; - /** A model name (`/`) or null (auto). */ - readonly compactionModel?: string | null; + readonly imageLimit?: number; + readonly compactionModel?: string | null; } // ─── Message queue (steering) ───────────────────────────────────────────────── @@ -604,15 +561,18 @@ export interface SetVisionSettingsRequest { * works regardless of generation state. The resolved queue + whether a turn was * started are returned in `QueueResponse`. * - * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. + * `text` must be a string; empty/whitespace text requires at least one image. + * Invalid bodies return HTTP 400 `{ error }`. */ export interface QueueRequest { - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly images?: readonly ImageInput[]; + readonly computerId?: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -624,9 +584,9 @@ export interface QueueRequest { * the chat channel as usual. */ export interface QueueResponse { - readonly conversationId: string; - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly conversationId: string; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; } /** @@ -645,9 +605,9 @@ export interface QueueResponse { * fire-and-forget a cancel and reconcile from the surface. */ export interface QueueCancelResponse { - readonly conversationId: string; - readonly cancelled: boolean; - readonly queue: readonly QueuedMessage[]; + readonly conversationId: string; + readonly cancelled: boolean; + readonly queue: readonly QueuedMessage[]; } // ─── Per-conversation LSP status ────────────────────────────────────────────── @@ -657,39 +617,39 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; /** One language server's status as reported to the frontend. */ export interface LspServerInfo { - /** Stable server id, e.g. "typescript", "luau-lsp". */ - readonly id: string; - /** Human-readable display name. */ - readonly name: string; - /** The resolved workspace root the server is (or would be) rooted at (absolute). */ - readonly root: string; - /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ - readonly extensions: readonly string[]; - /** Current connection state. */ - readonly state: LspServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted - * when not yet resolved. Surfaces config-shadow debugging to the status caller - * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). - */ - readonly configSource?: string; + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + readonly state: LspServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted + * when not yet resolved. Surfaces config-shadow debugging to the status caller + * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). + */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/lsp`. */ export interface LspStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the LSP connects on, or `null` when no - * cwd has been set for the conversation (then `servers` is empty). When - * non-null, this is the effective cwd — a relative persisted cwd resolved - * against the conversation's workspace `defaultCwd`. - */ - readonly cwd: string | null; - /** The language servers configured for `cwd` and their live state. */ - readonly servers: readonly LspServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ + readonly cwd: string | null; + /** The language servers configured for `cwd` and their live state. */ + readonly servers: readonly LspServerInfo[]; } // ─── MCP status ────────────────────────────────────────────────────── @@ -698,29 +658,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte /** One MCP server's status as reported to the frontend. */ export interface McpServerInfo { - /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ - readonly id: string; - /** Current connection state. */ - readonly state: McpServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** Number of tools discovered from this server. */ - readonly toolCount: number; - /** Which config source this server was resolved from. */ - readonly configSource?: string; + /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ + readonly id: string; + /** Current connection state. */ + readonly state: McpServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source this server was resolved from. */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/mcp`. */ export interface McpStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the MCP servers are configured for, or - * `null` when no cwd has been set for the conversation (then `servers` is - * empty). Mirrors the LSP status endpoint behavior. - */ - readonly cwd: string | null; - /** The MCP servers configured for `cwd` and their live state. */ - readonly servers: readonly McpServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the MCP servers are configured for, or + * `null` when no cwd has been set for the conversation (then `servers` is + * empty). Mirrors the LSP status endpoint behavior. + */ + readonly cwd: string | null; + /** The MCP servers configured for `cwd` and their live state. */ + readonly servers: readonly McpServerInfo[]; } /** @@ -734,17 +694,17 @@ export interface McpStatusResponse { * prefix is byte-identical to a real turn (which is what makes the cache hit). */ export interface WarmRequest { - /** The conversation whose prompt cache to warm. */ - readonly conversationId: string; + /** The conversation whose prompt cache to warm. */ + readonly conversationId: string; - /** - * The model name in `/` form the conversation uses, so - * the warm resolves the same provider + prefix. Omit to use the server default. - */ - readonly model?: string; + /** + * The model name in `/` form the conversation uses, so + * the warm resolves the same provider + prefix. Omit to use the server default. + */ + readonly model?: string; - /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ - readonly cwd?: string; + /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ + readonly cwd?: string; } /** @@ -756,26 +716,26 @@ export interface WarmRequest { * server responds `409` with `{ error }` instead of this body. */ export interface WarmResponse { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; - /** - * **Cache rate** — what fraction of THIS request's prompt was served from cache: - * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). - * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) - */ - readonly cachePct: number; - /** - * **Expected cache (retention)** — of the cacheable prefix this warm touched, how - * much was still warm and read back vs. had to be (re)written: - * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the - * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still - * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite - * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's - * tiny fresh probe makes ~equal, but which on a real turn reflects new content). - */ - readonly expectedCacheRate: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** — what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** — of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still + * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite + * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's + * tiny fresh probe makes ~equal, but which on a real turn reflects new content). + */ + readonly expectedCacheRate: number; } // ─── WebSocket chat ops ─────────────────────────────────────────────────────── @@ -790,7 +750,9 @@ export interface WarmResponse { * `AgentEvent`s (each carries `conversationId`). */ export interface ChatSendMessage extends ChatRequest { - readonly type: "chat.send"; + readonly type: "chat.send"; + /** Optional client correlation token, echoed by admission replies. Not an idempotency key. */ + readonly requestId?: string; } /** @@ -800,8 +762,25 @@ export interface ChatSendMessage extends ChatRequest { * carrier. */ export interface ChatDeltaMessage { - readonly type: "chat.delta"; - readonly event: AgentEvent; + readonly type: "chat.delta"; + readonly event: AgentEvent; +} + +/** + * Replay or a single live payload exceeded the WS delivery budget. No partial + * replay/placeholder payload is sent; live subscription remains active. The + * client must refetch committed history over HTTP and reconcile provisional + * content by turn/step identity (not append duplicates). While this turn is + * active, use a bounded single-flight history refresh (step-complete may precede + * persistence), and a final refresh on turn-ended including aborted/failed + * terminals, so omitted data committed after this notice is recovered. + * This is not a transport failure and must not trigger reconnect/resubscribe. + */ +export interface ChatResyncRequiredMessage { + readonly type: "chat.resync-required"; + readonly conversationId: string; + readonly turnId?: string; + readonly reason: "replay-limit" | "payload-limit"; } /** @@ -810,9 +789,39 @@ export interface ChatDeltaMessage { * `TurnErrorEvent` inside a `chat.delta`.) */ export interface ChatErrorMessage { - readonly type: "chat.error"; - readonly conversationId?: string; - readonly message: string; + readonly type: "chat.error"; + readonly conversationId?: string; + readonly requestId?: string; + readonly message: string; +} + +/** Sent to the sender only AFTER its opening message is stored or queue insertion completes. */ +export interface ChatAcceptedMessage { + readonly type: "chat.accepted"; + readonly conversationId: string; + readonly requestId: string; +} + +/** A rejected watcher does not disconnect the socket or reject chat input. */ +export interface ChatSubscriptionRejectedMessage { + readonly type: "chat.subscription-rejected"; + readonly conversationId: string; + readonly reason: "subscription-limit"; +} + +/** + * Accepted chat.send deferred behind compaction, sent only to its sender AFTER + * storage/insertion. Not a transcript event: remove the correlated optimistic + * opening message and display the authoritative message-queue surface instead. + * Live user-message/steering and history resync remain transcript-authoritative. + * Old clients safely ignore this additive message. Ordinary starts use chat.accepted. + */ +export interface ChatQueuedMessage { + readonly type: "chat.queued"; + readonly conversationId: string; + readonly reason: "compaction"; + readonly messageId: string; + readonly requestId?: string; } /** @@ -826,14 +835,17 @@ export interface ChatErrorMessage { * `turn-start`), then streams subsequent live events. If no turn is in-flight, * nothing is replayed (the client relies on `GET /conversations/:id` history). * A client infers "generating" from a replayed `turn-start` with no matching - * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`. + * `done`/`turn-sealed` yet. A bounded transport may replace the WHOLE replay + * with chat.resync-required; in that case its turnId identifies an active turn, + * and the client must recover committed content over HTTP (see that message). + * Idempotent per `(connection, conversationId)`. * - * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs - * `chat.subscribe` for conversations it is viewing but did not send to. + * NOTE: `chat.send` tries to auto-subscribe the sender within the shared watcher budget. + * Watcher rejection is nonfatal and never gates input admission. */ export interface ChatSubscribeMessage { - readonly type: "chat.subscribe"; - readonly conversationId: string; + readonly type: "chat.subscribe"; + readonly conversationId: string; } /** @@ -843,30 +855,34 @@ export interface ChatSubscribeMessage { * the socket closes — again WITHOUT aborting any in-flight turn. */ export interface ChatUnsubscribeMessage { - readonly type: "chat.unsubscribe"; - readonly conversationId: string; + readonly type: "chat.unsubscribe"; + readonly conversationId: string; } /** * Client → server: enqueue a message onto a conversation's message queue while * a turn is generating (steering). The WebSocket counterpart of the HTTP - * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is - * confirmed by the message-queue SURFACE updating (the FE renders the queue - * from the surface, not from a reply here); a failure (malformed/empty text, + * `POST /conversations/:id/queue` (`QueueRequest`). With `requestId`, acceptance is + * confirmed by `chat.accepted` after queue insertion or opening-message storage. + * The queue surface remains authoritative for its current contents. Without a + * correlation token, legacy clients use surface/stream updates. A failure (malformed/empty text, * unknown conversation) arrives as a `chat.error`. When no turn is active, the * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a * client reuses this op for both "queue while generating" and "send" (the * latter being equivalent to `chat.send`). */ export interface ChatQueueMessage { - readonly type: "chat.queue"; - readonly conversationId: string; - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly requestId?: string; + readonly images?: readonly ImageInput[]; + readonly computerId?: string; + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -882,9 +898,56 @@ export interface ChatQueueMessage { * or the enqueue response). */ export interface ChatQueueCancelMessage { - readonly type: "chat.queue.cancel"; - readonly conversationId: string; - readonly messageId: string; + readonly type: "chat.queue.cancel"; + readonly conversationId: string; + readonly messageId: string; +} + +/** + * Client → server: declare THIS connection's active workspace (plan 06 + * §6.4). Workspace switching is instant and client-side, so the server + * must be told explicitly — on receipt the server records the + * connection's workspace and RE-PUSHES the surface catalog filtered to + * that workspace (surfaces contributed by plugins that are not usable in + * the workspace are omitted). Clients that never send it keep the + * unfiltered (global) catalog — backward compatible. Owned by plan 06 + * (backend contract + server handling); plan 07 sends it from the client. + */ +export interface WorkspaceSetMessage { + readonly type: "workspace.set"; + readonly workspaceId: string; +} + +/** + * Broadcast to all connected WS clients after every successful layout write + * (plan 02 §2.5 — the `layout-chat` provider's PUT / setTree / DELETE / ops). + * Carries the FULL tree for ONE workspace (decision 7 — no patch protocol); + * the broadcast is a global fan-out and CLIENTS FILTER by their active + * workspace (the workspaceId is the payload, not a subscription). Additive to + * `WsServerMessage` — next to `extensions-changed`, a contract addition, not + * core layout knowledge (D-L6: no core extension or transport learns the + * layout domain; the transport ferries the message verbatim). + */ +export interface LayoutChangedMessage { + readonly type: "layout"; + readonly workspaceId: string; + readonly tree: LayoutTree; +} + +/** + * Broadcast to all connected WS clients after every successful home + * tile-layout write (the `home-tile-layout` plugin's PUT / DELETE). Carries + * the FULL document for ONE device orientation (`mode`: "wide" = landscape/ + * desktop, "tall" = portrait/mobile — two independent shared layouts); the + * broadcast is a global fan-out and CLIENTS FILTER by their active mode. + * Additive to `WsServerMessage`, the same verbatim-ferry pattern as + * `LayoutChangedMessage` — no core extension or transport learns the tile + * domain. + */ +export interface TileLayoutChangedMessage { + readonly type: "tile-layout"; + readonly mode: TileGeometryMode; + readonly data: TileLayoutData; } /** @@ -892,48 +955,69 @@ export interface ChatQueueCancelMessage { * ops. A server discriminates on `type`. */ export type WsClientMessage = - | SurfaceClientMessage - | ChatSendMessage - | ChatSubscribeMessage - | ChatUnsubscribeMessage - | ChatQueueMessage - | ChatQueueCancelMessage; + | SurfaceClientMessage + | ChatSendMessage + | ChatSubscribeMessage + | ChatUnsubscribeMessage + | ChatQueueMessage + | ChatQueueCancelMessage + | WorkspaceSetMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ export type WsServerMessage = - | SurfaceServerMessage - | ChatDeltaMessage - | ChatErrorMessage - | ConversationOpenMessage - | ConversationStatusChangedMessage - | ConversationCompactedMessage; + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage + | ConversationOpenMessage + | ConversationTabGroupChangedMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage + | ExtensionsChangedMessage + | LayoutChangedMessage + | TileLayoutChangedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── +/** A visual grouping change, globally broadcast to every connected client. */ +export interface ConversationTabGroupChangedMessage { + readonly type: "conversation.tabGroupChanged"; + readonly conversationId: string; + /** The conversation's persisted workspace id. */ + readonly workspaceId: string; + /** The new EFFECTIVE tab group parent; null = ungrouped. */ + readonly tabGroupParentId: string | null; +} + /** * Broadcast to all connected WS clients when a conversation is "opened" (e.g. * via the CLI `--open` flag). The frontend decides whether to open/focus a tab * — the backend just signals. Additive to `WsServerMessage`. */ export interface ConversationOpenMessage { - readonly type: "conversation.open"; - readonly conversationId: string; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; - /** - * The conversation's persisted per-conversation model when set (absent = - * default provider model), so a frontend can stamp the new tab with the - * conversation's ACTUAL model rather than the globally-selected one - * (fix-toolcall-duplicate-keys plan Part 4). - */ - readonly model?: string; + readonly type: "conversation.open"; + /** Effective visual group parent; absent = ungrouped. */ + readonly tabGroupParentId?: string; + readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; + /** + * The conversation's persisted per-conversation model when set (absent = + * default provider model), so a frontend can stamp the new tab with the + * conversation's ACTUAL model rather than the globally-selected one + * (fix-toolcall-duplicate-keys plan Part 4). + */ + readonly model?: string; } /** @@ -942,15 +1026,15 @@ export interface ConversationOpenMessage { * devices in real time. */ export interface ConversationStatusChangedMessage { - readonly type: "conversation.statusChanged"; - readonly conversationId: string; - readonly status: ConversationStatus; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -969,6 +1053,281 @@ export interface ConversationCompactedMessage { readonly historyEpoch: number; } +// ─── Plugin manager (plan 05) ──────────────────────────────────────────────── +// +// The wire shapes for the plugin management API (HTTP routes + the +// `extensions-changed` broadcast). Tier/trust mirror the kernel's +// `PluginTier`/`TrustLevel` (stable three-value enums — the manager's +// manifest→row mapping is the drift detector: any kernel-side change fails +// the assignment there at compile time). + +/** Classification tier of a plugin (mirror of the kernel's `PluginTier`). */ +export type PluginTier = "core" | "providers" | "standard"; + +/** Trust level of a plugin (mirror of the kernel's `TrustLevel`). */ +export type PluginTrustLevel = "bundled" | "local" | "external"; + +/** How a persisted override came to be (absent in plan-04-era records). */ +export type PluginOverrideKind = "unloaded" | "disabled"; + +/** The persisted user override for a discovered plugin (wire shape). */ +export interface PluginOverrideInfo { + readonly kind?: PluginOverrideKind; + readonly reason?: string; + readonly disabledAt?: string; +} + +/** A plugin currently loaded and live — the manager's `loaded` table rows. */ +export interface LoadedPluginRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + /** True for tier "core" — the server refuses to unload it. */ + readonly locked: boolean; + readonly dependsOn: readonly string[]; + /** + * The plugin's frontend section (plan 03), present when the loaded plugin + * declares one AND its entry file is servable (resolved inside the plugin + * directory). `apiVersion`/`entry` mirror the manifest; `bust` is the entry + * file's mtimeMs — the SAME value the backend loader uses as its `?v=` + * cache-bust query — so clients can build content-versioned import URLs. + */ + readonly frontend?: LoadedPluginFrontend; +} + +/** The wire shape of a servable frontend section (plan 03). */ +export interface LoadedPluginFrontend { + readonly apiVersion: string; + readonly entry: string; + readonly bust: string; +} + +/** A plugin the system refused or failed to load — disabled-with-reason. */ +export interface DisabledWithReasonRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + readonly reason: string; +} + +/** A plugin present on disk but not loaded — the manager's second table. */ +export interface DiscoveredPluginRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + /** Absolute plugin directory. */ + readonly dir: string; + /** + * The persisted user override, if any. Absent = a fresh discovery the + * consent gate renders UNCHECKED; present rows distinguish a deliberate + * unload (`kind: "unloaded"`) from an explicit disable (`"disabled"`). + */ + readonly override?: PluginOverrideInfo; +} + +/** An override record whose plugin directory is gone — a greyed-out row. */ +export interface MissingPluginRow { + readonly id: string; + readonly reason?: string; + readonly disabledAt?: string; +} + +/** Response for `GET /plugins` — the two manager tables + the missing strip. */ +export interface PluginListResponse { + readonly loaded: readonly LoadedPluginRow[]; + readonly disabledWithReason: readonly DisabledWithReasonRow[]; + readonly discovered: readonly DiscoveredPluginRow[]; + readonly missing: readonly MissingPluginRow[]; +} + +/** One directory that failed scan validation (manifest missing/unparsable/invalid). */ +export interface InvalidPluginDir { + readonly dir: string; + readonly errors: readonly string[]; +} + +/** One directory whose (valid) manifest is incompatible with the kernel. */ +export interface IncompatiblePluginDir { + readonly dir: string; + readonly id: string; + readonly reason: string; +} + +/** + * Response for `POST /plugins/scan` — the manual scan's product (§5): newly + * discovered plugins are UNCHECKED (the consent gate — the scan never + * auto-loads), broken directories report their validation errors. + */ +export interface PluginScanResponse { + /** The plugins root that was scanned. */ + readonly root: string; + /** Fresh discoveries: loadable, not loaded, no override. */ + readonly newPlugins: readonly DiscoveredPluginRow[]; + /** Directories that failed manifest/entry validation. */ + readonly invalid: readonly InvalidPluginDir[]; + /** Valid manifests incompatible with the running kernel. */ + readonly incompatible: readonly IncompatiblePluginDir[]; +} + +/** The mutation a plugin-management route performed. */ +export type PluginAction = "load" | "unload" | "reload" | "forget"; + +/** Why a plugin operation was refused (HTTP 409/404 — see the route docs). */ +export type PluginBlockReason = + /** Core plugins cannot be unloaded (never force-able). */ + | "core-locked" + /** Unloading would leave zero loaded providers (never force-able). */ + | "last-provider" + /** Loaded dependents exist — retry with `force` (the dependents are listed). */ + | "cascade-needs-force" + /** The target plugin is not currently loaded. */ + | "not-loaded" + /** No such plugin was found (no loadable directory with that id). */ + | "not-found" + /** The target is already loaded (load; use reload). */ + | "already-loaded" + /** The engine refused or failed the operation (reason in `message`). */ + | "engine-refused"; + +/** Successful plugin-management operation (HTTP 200). */ +export interface PluginOpSuccess { + readonly ok: true; + readonly action: PluginAction; + readonly id: string; + /** Forced unload: the dependents that were unloaded before the target. */ + readonly cascade?: readonly string[]; + /** Forced reload: dependents unloaded around the target (engine report). */ + readonly unloadedDependents?: readonly string[]; + /** Forced reload: dependents reloaded after the target (engine report). */ + readonly reloadedDependents?: readonly string[]; + /** Whether a saved override record was removed (load / reload / forget). */ + readonly clearedOverride?: boolean; + /** Forced ops stop ALL agents first — how many in-flight turns aborted. */ + readonly stoppedAgents?: number; +} + +/** Refused plugin-management operation (HTTP 404 / 409). */ +export interface PluginOpBlocked { + readonly ok: false; + readonly action: PluginAction; + readonly id: string; + readonly reason: PluginBlockReason; + /** Human-readable reason (server-log grammar) for the UI to surface. */ + readonly message: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; +} + +/** Response for the plugin-management mutation routes. */ +export type PluginOpResponse = PluginOpSuccess | PluginOpBlocked; + +/** + * Broadcast to ALL connected WS clients after every plugin-manager mutation + * (load / unload / reload / scan / forget) so every connected frontend + * refetches the extension list AND the surface catalog (§11 multi-client + * coherence). The payload is intentionally minimal — the client refreshes + * from `GET /plugins` + the surface catalog rather than diffing here. + */ +export interface ExtensionsChangedMessage { + readonly type: "extensions-changed"; + readonly action: "load" | "unload" | "reload" | "scan" | "forget"; + /** The plugin the action applied to (absent for a scan). */ + readonly pluginId?: string; +} + +// ─── Per-workspace plugin enablement (plan 06 §6.6) ───────────────────────── +// +// The workspace modal's read+write contract for "is this plugin used HERE". +// The routes are thin shells over the workspace-plugin-config core service; +// the state-transition + validation logic lives THERE (pure, unit-tested). + +/** + * The wire form of a per-workspace plugin state: `"enabled"` / `"disabled"` + * — an explicit record — or the string `"undefined"` for NO record (the + * blank slate, §7). The triple is spelled out on the wire (rather than + * omitting the field) so a client can render all three states uniformly. + */ +export type WorkspacePluginStateValue = "enabled" | "disabled" | "undefined"; + +/** + * One row of `GET /workspaces/:id/plugins` — a plugin's effective state in + * the workspace plus the metadata the modal needs (unloaded → greyed; + * core → locked). + */ +export interface WorkspacePluginRow { + readonly pluginId: string; + readonly tier: PluginTier; + readonly state: WorkspacePluginStateValue; + /** Whether the plugin is currently loaded globally (plan 05). */ + readonly loaded: boolean; + /** True for tier "core" — the per-workspace state cannot be changed. */ + readonly locked: boolean; +} + +/** + * Response for `GET /workspaces/:id/plugins` — this workspace's effective + * plugin states. READING triggers read-through materialization (§6.1): + * the first read snapshots inherited records from the default workspace + * (Core → `enabled` always). `enabledProviderCount` is folded in for + * convenience; the dedicated `providers-status` route reports the same + * signal without parsing the full list. + */ +export interface WorkspacePluginsResponse { + readonly workspaceId: string; + readonly plugins: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5 guardrail). */ + readonly enabledProviderCount: number; +} + +/** + * Request body for `PUT /workspaces/:id/plugins/:pluginId` — set/clear + * this workspace's state for one plugin. `"unset"` returns it to the + * blank slate (no record). The server refuses (`core-locked` / + * `unloaded`, HTTP 409) changes for CORE plugins and for plugins that are + * not currently loaded — strict layering: only the global plugin-manager + * loads code (§7). + */ +export interface SetWorkspacePluginStateRequest { + readonly state: "enabled" | "disabled" | "unset"; +} + +/** Response for `PUT /workspaces/:id/plugins/:pluginId` (HTTP 200). */ +export interface SetWorkspacePluginStateResponse { + readonly workspaceId: string; + readonly pluginId: string; + /** The resulting effective state (`"undefined"` after `"unset"`). */ + readonly state: WorkspacePluginStateValue; +} + +/** Why a `PUT /workspaces/:id/plugins/:pluginId` was refused (HTTP 409). */ +export type SetWorkspacePluginStateBlockReason = "core-locked" | "unloaded"; + +/** Refused per-workspace state change (HTTP 409). */ +export interface SetWorkspacePluginStateBlocked { + readonly workspaceId: string; + readonly pluginId: string; + readonly reason: SetWorkspacePluginStateBlockReason; + /** Human-readable reason (server-log grammar) for the UI to surface. */ + readonly message: string; +} + +/** Response for `GET /workspaces/:id/plugins/providers-status` (§6.5). */ +export interface WorkspacePluginProvidersStatusResponse { + readonly workspaceId: string; + /** + * Loaded provider-tier plugins effectively `enabled` in this workspace. + * `0` is the first-boot guardrail signal (§7): show a visible "no + * providers enabled" prompt instead of a silently empty model picker. + */ + readonly enabledProviderCount: number; +} + /** * Response for `GET /conversations` — the list of all known conversations, * sorted by `lastActivityAt` descending (most recent first). Each entry carries @@ -976,7 +1335,7 @@ export interface ConversationCompactedMessage { * Optional `?q=` query param filters by id prefix (short-id resolution). */ export interface ConversationListResponse { - readonly conversations: readonly ConversationMeta[]; + readonly conversations: readonly ConversationMeta[]; } /** @@ -986,9 +1345,9 @@ export interface ConversationListResponse { * `turnId` is the turn that produced the message (absent if no turn ran). */ export interface LastMessageResponse { - readonly conversationId: string; - readonly content: string; - readonly turnId?: string; + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; } /** @@ -996,22 +1355,33 @@ export interface LastMessageResponse { * signal was broadcast to connected WS clients. */ export interface OpenConversationResponse { - readonly conversationId: string; + readonly conversationId: string; +} + +/** Body for `PUT /conversations/:id/tab-group`. null = remove from its group (ungroup). */ +export interface SetTabGroupRequest { + readonly parentId: string | null; +} + +/** Response for `PUT /conversations/:id/tab-group` — the new EFFECTIVE value. */ +export interface TabGroupResponse { + readonly conversationId: string; + readonly tabGroupParentId: string | null; } /** * Request body for `PUT /conversations/:id/title` — set a human-readable title. */ export interface SetTitleRequest { - readonly title: string; + readonly title: string; } /** * Response for `GET/PUT /conversations/:id/title` — the current title. */ export interface TitleResponse { - readonly conversationId: string; - readonly title: string; + readonly conversationId: string; + readonly title: string; } /** @@ -1019,10 +1389,10 @@ export interface TitleResponse { * history was compacted (old messages summarized, recent messages retained). */ export interface CompactResponse { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -1030,15 +1400,15 @@ export interface CompactResponse { * at which automatic compaction triggers (0 = manual only). */ export interface CompactPercentResponse { - readonly conversationId: string; - readonly threshold: number; + readonly conversationId: string; + readonly threshold: number; } /** * Request body for `PUT /conversations/:id/compact-percent`. */ export interface SetCompactPercentRequest { - readonly threshold: number; + readonly threshold: number; } // ─── Workspaces ─────────────────────────────────────────────────────────────── @@ -1049,28 +1419,33 @@ export interface SetCompactPercentRequest { * an existing workspace is returned as-is. */ export interface EnsureWorkspaceRequest { - /** Display title. Default: the workspace id. Only used on create. */ - readonly title?: string; - /** Default cwd. Default: null (inherit server default). Only used on create. */ - readonly defaultCwd?: string | null; + /** Display title. Default: the workspace id. Only used on create. */ + readonly title?: string; + /** Default cwd. Default: null (inherit server default). Only used on create. */ + readonly defaultCwd?: string | null; } /** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ export interface WorkspaceResponse extends Workspace {} -/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ +/** + * Response of `GET /workspaces` — the workspaces sorted by `lastActivityAt` + * desc. The optional `?archived=` query selects which workspaces are included: + * `"true"` → archived only, `"all"` → every workspace; absent (or any other + * value) → non-archived only (the home grid's default view). + */ export interface WorkspaceListResponse { - readonly workspaces: readonly WorkspaceEntry[]; + readonly workspaces: readonly WorkspaceEntry[]; } /** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ export interface SetWorkspaceTitleRequest { - readonly title: string; + readonly title: string; } /** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; + readonly defaultCwd: string | null; } /** @@ -1079,12 +1454,12 @@ export interface SetWorkspaceDefaultCwdRequest { * workspace entity is deleted. `"default"` is non-deletable (HTTP 409). */ export interface DeleteWorkspaceResponse { - readonly workspaceId: string; - /** Conversations that were closed (status → "closed") by this delete. */ - readonly closedCount: number; + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; } -// ─── Computers (SSH handoff #2) ───────────────────────────────────────────── +// ─── Computers ─────────────────────────────────────────────────────────────── /** * Response of `GET /computers` — every remote computer discovered from the @@ -1094,7 +1469,7 @@ export interface DeleteWorkspaceResponse { * block to `~/.ssh/config` and Dispatch discovers it on the next read. */ export interface ComputerListResponse { - readonly computers: readonly ComputerEntry[]; + readonly computers: readonly ComputerEntry[]; } /** @@ -1111,10 +1486,10 @@ export interface ComputerResponse extends Computer {} * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. */ export interface ComputerStatusResponse { - readonly alias: string; - readonly state: "disconnected" | "connecting" | "connected" | "error"; - readonly error?: string; - readonly knownHost: boolean; + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; } /** @@ -1126,7 +1501,7 @@ export interface ComputerStatusResponse { * a 400). Mirrors the cwd/model PUT clear semantics. */ export interface SetConversationComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -1135,8 +1510,8 @@ export interface SetConversationComputerRequest { * the workspace default → local). Parallel to `CwdResponse`. */ export interface ConversationComputerResponse { - readonly conversationId: string; - readonly computerId: string | null; + readonly conversationId: string; + readonly computerId: string | null; } /** @@ -1146,7 +1521,7 @@ export interface ConversationComputerResponse { * `computerId` of their own inherit this. */ export interface SetWorkspaceDefaultComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -1156,25 +1531,129 @@ export interface SetWorkspaceDefaultComputerRequest { * failure reason (e.g. auth refused, host unreachable) when `ok` is false. */ export interface TestComputerResponse { - readonly alias: string; - readonly ok: boolean; - readonly error?: string; + readonly alias: string; + readonly ok: boolean; + readonly error?: string; } -// ── Provider concurrency limits (transport-contract@0.23.0) ─────────────────── +// ─── Heartbeat ─────────────────────────────────────────────────────────────── + +/** + * The per-workspace Heartbeat config — a single record stored per workspace. + * + * A heartbeat is an AI that runs on a configurable loop per workspace: when + * `enabled`, the backend summons a NEW conversation every `intervalMinutes` + * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the + * opening user message, and inherits the workspace's computer (SSH) + cwd + + * standard tool kit. The main purpose is monitoring (e.g. checking if chats + * are stuck). + * + * `model` is a model name in `/` form (one of the strings + * from `GET /models`), or the empty string to use the server default. + * `reasoningEffort` is `null` to inherit the workspace default, or an explicit + * level. The scheduler resets its timer after each run completes (not a fixed + * wall-clock schedule); on backend restart it resumes scheduling for enabled + * heartbeats. + * + * `inactiveOnly` (default `true`) gates each fire on the configured workspace + * having NO active agents (no conversation with status `"active"` or `"queued"`): + * the heartbeat stays quiet while the user is actively working, and only fires + * when the workspace is idle. Set `false` to fire unconditionally. + */ +export interface HeartbeatConfig { + /** Whether the heartbeat loop is active for this workspace. */ + readonly enabled: boolean; + /** + * When `true` (the default), the heartbeat SKIPS a fire when the configured + * workspace has any active agents — conversations whose persisted status is + * `"active"` (driving a turn) or `"queued"` (waiting on the message queue). + * The fire is silently skipped (no run is recorded); the scheduler re-arms + * and tries again at the next interval. When `false`, the heartbeat fires + * unconditionally regardless of workspace activity. The spawned heartbeat + * conversation lives in the DEDICATED heartbeat workspace, so it never + * counts as an "active agent" of the configured workspace (no self-block). + */ + readonly inactiveOnly: boolean; + /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ + readonly systemPrompt: string; + /** Task prompt sent as the first user message when the heartbeat fires. */ + readonly taskPrompt: string; + /** How often to fire, in minutes (default 30). */ + readonly intervalMinutes: number; + /** Model name (`/`), or empty string = server default. */ + readonly model: string; + /** Reasoning-effort level, or `null` = inherit the workspace default. */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are + * optional; only the provided fields are applied. `reasoningEffort` accepts + * `null` to clear to the workspace default. `intervalMinutes` must be a positive + * integer (clamped server-side to a minimum of 1). An unrecognized + * `reasoningEffort` → HTTP 400. + */ +export interface UpdateHeartbeatRequest { + readonly enabled?: boolean; + readonly inactiveOnly?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** The status of a single heartbeat run. */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** + * One heartbeat run — created each time the heartbeat fires. A run tracks the + * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601 + * timestamp. `status` is `"running"` while the turn is in flight, + * `"completed"` when the turn sealed normally, or `"stopped"` when the user + * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`). + */ +export interface HeartbeatRun { + readonly id: string; + readonly conversationId: string; + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; +} + +/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */ +export interface HeartbeatRunsResponse { + readonly runs: readonly HeartbeatRun[]; +} + +/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export interface StopHeartbeatRunResponse { + readonly ok: true; +} + +// ─── Search backend selection ───────────────────────────────────────────────── + +/** + * Re-exported wire types for the global search-backend selection + * (`GET/PUT /search/config`). The web frontend consumes these directly. + */ +export type { + SearchBackendAvailability, + SearchBackendConfigResponse, + UpdateSearchConfigRequest, +} from "@dispatch/wire"; + +// ─── Provider concurrency limits ────────────────────────────────────────────── /** * Response of `GET /concurrency/limits` — all providers with configured * concurrency limits. Each entry pairs a provider id (e.g. "umans", - * "openai-compat") with its maximum concurrent in-flight requests. Providers + * "opencode-1") with its maximum concurrent in-flight requests. Providers * not listed here have no limit (unlimited). */ export interface ConcurrencyLimitsResponse { - readonly limits: readonly { - readonly providerId: string; - readonly limit: number; - }[]; + readonly limits: readonly { readonly providerId: string; readonly limit: number }[]; } + /** * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency * limit for a provider. `limit` must be a positive integer. When a limit is @@ -1182,13 +1661,15 @@ export interface ConcurrencyLimitsResponse { * sent immediately. */ export interface SetConcurrencyLimitRequest { - readonly limit: number; + readonly limit: number; } + /** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ export interface ConcurrencyLimitResponse { - readonly providerId: string; - readonly limit: number; + readonly providerId: string; + readonly limit: number; } + /** * One provider's live concurrency status. * @@ -1209,23 +1690,24 @@ export interface ConcurrencyLimitResponse { * banner when the limit was auto-reduced (present only when `autoReduced`). */ export interface ConcurrencyStatusEntry { - readonly providerId: string; - readonly limit: number; - readonly inFlight: number; - readonly queued: number; - readonly paused: boolean; - readonly pausedUntil?: number; - readonly cooldownMs: number; - readonly autoReduced: boolean; - readonly autoReducedFrom?: number; - readonly notice?: string; + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + readonly pausedUntil?: number; + readonly cooldownMs: number; + readonly autoReduced: boolean; + readonly autoReducedFrom?: number; + readonly notice?: string; } + /** * Response of `GET /concurrency/status` — live status for every provider with a * configured limit. Providers without a limit are absent (they are unlimited). */ export interface ConcurrencyStatusResponse { - readonly providers: readonly ConcurrencyStatusEntry[]; + readonly providers: readonly ConcurrencyStatusEntry[]; } // ─── Provider concurrency cooldown ──────────────────────────────────────────── @@ -1237,8 +1719,8 @@ export interface ConcurrencyStatusResponse { * When no cooldown was explicitly set, the server default (350ms) is returned. */ export interface ConcurrencyCooldownResponse { - readonly providerId: string; - readonly cooldownMs: number; + readonly providerId: string; + readonly cooldownMs: number; } /** @@ -1248,31 +1730,9 @@ export interface ConcurrencyCooldownResponse { * subsequently recycled slots. */ export interface SetConcurrencyCooldownRequest { - readonly cooldownMs: number; + readonly cooldownMs: number; } -``` - ---- - -## 2026-09-24 delta (search-backend-selection — ADDITIVE, no version bump) - -GLOBAL search-backend selection routes (the `web_search` tool's search-mode engine; scrape/crawl -ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOVED entirely): - -- `GET /search/config` → `SearchBackendConfigResponse` (`{ backend, available: SearchBackendAvailability[] }` - — re-exported from `@dispatch/wire`, see the wire delta below). When the `tool-web-search` extension - is NOT loaded the route degrades like heartbeat: 200 with the defaults (`backend: "firecrawl"`, - firecrawl-only availability). -- `PUT /search/config` body `UpdateSearchConfigRequest` (`{ backend: string }`) → 200 - `SearchBackendConfigResponse` | 400 (`{ error }` — unknown or not-configured backend) | 503 - (service not loaded). The umans entry appears in `available` only when the backend has - `UMANS_API_KEY`; an unconfigured backend is listed but `configured: false` — the UI disables it, - it is never an error. - -## Account usage (`transport-contract@0.26.0`, verbatim) - -```ts // ─── Account usage (Claude / Codex subscription quotas) ─────────────────────── // // Subscription quota usage polled from each provider's first-party account @@ -1418,4 +1878,5 @@ export interface AccountUsageResponse { /** Server clock (epoch-ms) when this response was built — use it to compute window progress. */ readonly servedAt: number; } + ``` diff --git a/backend-handoff.md b/backend-handoff.md index 998525b..f6a167a 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -18,6 +18,35 @@ > ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists > rebuilt). Recorded below. +## Milestone: Subscription and input delivery regression (2026-10-21) + +The user authorized one agent to implement both repositories in paired worktrees on +`feature/subscription-delivery-fix`. + +- Discovery remains global for metadata and lifecycle status. Only the focused chat and + explicit modal viewers subscribe to transcripts. Reopening a background chat catches up + history and metrics. Chat watchers and surfaces each own at most 128 of the server's + 256 shared slots; unsubscribes precede replacement subscriptions. +- Backend overflow rejects a watcher with `chat.subscription-rejected` (or a scoped surface + error) while preserving the socket and independent message admission. Logs include the + connection ID, subscription counts, and close code/reason. +- `chat.send` and `chat.queue` use correlated receipts. `chat.accepted` follows opening + message storage or queue insertion; `chat.queued` confirms compaction admission. Correlated + `chat.error` identifies rejected input. Ordinary stream echoes are not durable receipts. +- Inputs and images are saved per conversation in localStorage before transmission. Saving + failures preserve the composer and prevent transmission. A reload retains local-only tabs + with saved input; unconfirmed/rejected copies are visible and can be discarded explicitly. + There is no automatic retry: `requestId` is a correlation token, not an idempotency key. +- Reconnect recovery is limited to live viewers. Discovery and per-chat metrics requests + coalesce; metrics permit at most one catch-up request behind an in-flight request. +- Contracts remain transport-contract 0.26.0 and wire 0.13.0. Built declarations were refreshed + into the unchanged local file dependencies and the transport mirror was regenerated. +- Validation: frontend typecheck/check/build passed; **2,439 tests / 124 files**, twice. + Backend typecheck/check passed; **4,150 Vitest + 184 Bun integration tests** passed. + The real socket fixture validates 339 requests, admission at capacity, storage-before-receipt, + and disconnect before receipt using isolated test ports and in-memory storage. Details in + `reports/subscription-delivery-fix.md`. No merge or deployment is part of this task. + ## Milestone: Review 3.6 — history epochs (2026-10-20) - Consumed backend Step 3.5: required `historyEpoch` on HTTP history and WS compaction; diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index b1e17cf..e1d7d22 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,8 +1,10 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, ChatQueuedMessage, ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, @@ -27,7 +29,13 @@ export interface SurfaceSocketOptions { url: string; onMessage: (msg: SurfaceServerMessage) => void; onChat?: ( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ) => void; /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ onConversationOpen?: (msg: ConversationOpenMessage) => void; @@ -105,6 +113,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa const msg = parseServerMessage(ev.data); if (msg !== null) { if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || msg.type === "chat.delta" || msg.type === "chat.error" || msg.type === "chat.queued" || @@ -112,6 +122,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa ) { opts.onChat?.( msg as + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage | ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index abc0464..d167669 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -25,6 +25,8 @@ const VALID_SERVER_TYPES = new Set([ "surface", "update", "error", + "chat.accepted", + "chat.subscription-rejected", "chat.delta", "chat.error", "chat.queued", @@ -109,6 +111,24 @@ export function parseServerMessage(data: string): WsServerMessage | null { : { type: "error", message: parsed.message }; return msg; } + case "chat.accepted": { + if (typeof parsed.conversationId !== "string" || typeof parsed.requestId !== "string") + return null; + return { + type: "chat.accepted", + conversationId: parsed.conversationId, + requestId: parsed.requestId, + }; + } + case "chat.subscription-rejected": { + if (typeof parsed.conversationId !== "string" || parsed.reason !== "subscription-limit") + return null; + return { + type: "chat.subscription-rejected", + conversationId: parsed.conversationId, + reason: parsed.reason, + }; + } case "chat.delta": { const event = parsed.event; if (!isRecord(event)) return null; @@ -149,7 +169,11 @@ export function parseServerMessage(data: string): WsServerMessage | null { conversationId !== undefined ? { type: "chat.error", conversationId, message: parsed.message } : { type: "chat.error", message: parsed.message }; - return msg; + if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null; + return { + ...msg, + ...(typeof parsed.requestId === "string" ? { requestId: parsed.requestId } : {}), + }; } case "conversation.open": { if (typeof parsed.conversationId !== "string") return null; diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 62798d6..42d30ed 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -470,6 +470,7 @@ describe("App component interaction tests", () => { expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([ { type: "chat.queue", + requestId: expect.any(String), conversationId, workspaceId: "default", text, diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts index a9b28ff..63f58ad 100644 --- a/src/app/chat-registry.svelte.ts +++ b/src/app/chat-registry.svelte.ts @@ -3,10 +3,11 @@ import { createLocalStore } from "../adapters/local-storage"; import type { SurfaceSocketHandle } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; +import { createChatStore, createPendingInputStorage } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import type { TabsStore } from "../features/tabs"; import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types"; +import { liveConversationIds } from "./subscription-budget"; import { randomId } from "./uuid"; /** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */ @@ -46,6 +47,7 @@ export function createChatRegistry(deps: { const chatStores = new Map(); // Includes in-flight loads: repeated activation must not start a second load. const loadedConversationIds = new Set(); + const loadingConversationIds = new Set(); // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a // watch on a conversation's live turn stream WITHOUT opening a tab. Separate @@ -53,6 +55,26 @@ export function createChatRegistry(deps: { // and a tab's conversation reuses its own store (see `watchConversation`). // Deltas are routed here in addition to `chatStores`. const watchStores = new Map(); + const watchedIds = new Set(); + const subscribedIds = new Set(); + + function syncChatSubscriptions(): void { + const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds); + for (const id of subscribedIds) { + if (!desired.has(id)) { + deps.send({ type: "chat.unsubscribe", conversationId: id }); + subscribedIds.delete(id); + } + } + for (const id of desired) { + if (subscribedIds.has(id)) continue; + subscribedIds.add(id); + deps.send({ type: "chat.subscribe", conversationId: id }); + if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) { + (chatStores.get(id) ?? watchStores.get(id))?.resync(); + } + } + } /** * Coalesce live chat events to at most one fold + render per animation @@ -77,6 +99,11 @@ export function createChatRegistry(deps: { function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ + ...(deps.storage === undefined + ? {} + : { + pendingInputStorage: createPendingInputStorage(deps.storage, conversationId), + }), conversationId, createRequestId: randomId, model, @@ -128,30 +155,28 @@ export function createChatRegistry(deps: { const store = chatStores.get(conversationId) ?? watchStores.get(conversationId); if (store === undefined) return; loadedConversationIds.add(conversationId); - void store.load(); + loadingConversationIds.add(conversationId); + void store.load().finally(() => loadingConversationIds.delete(conversationId)); } function refreshActiveChat(): void { + syncChatSubscriptions(); const activeId = deps.tabsStore.activeConversationId; if (activeId !== null) ensureLoaded(activeId); activeChat = getActiveChat(); } - /** - * Start watching a conversation's live turn events (`chat.subscribe`). Sent for - * EVERY open conversation — not just the active one — so a backgrounded tab keeps - * streaming a running turn, and a reloaded/second client re-attaches to an - * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; - * the socket queues it until the connection is open. NOT needed right after - * `chat.send` (that auto-subscribes the sending connection). - */ + /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */ function subscribeChat(conversationId: string): void { - deps.send({ type: "chat.subscribe", conversationId }); + void conversationId; + syncChatSubscriptions(); } /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ function unsubscribeChat(conversationId: string): void { - deps.send({ type: "chat.unsubscribe", conversationId }); + if (subscribedIds.delete(conversationId)) { + deps.send({ type: "chat.unsubscribe", conversationId }); + } } /** @@ -164,9 +189,11 @@ export function createChatRegistry(deps: { * Pair with {@link unwatchConversation} on close. */ function watchConversation(conversationId: string): ChatStore { + watchedIds.add(conversationId); // An open tab already has a live store + subscription — reuse it. const tabStore = chatStores.get(conversationId); if (tabStore !== undefined) { + syncChatSubscriptions(); ensureLoaded(conversationId); return tabStore; } @@ -174,8 +201,8 @@ export function createChatRegistry(deps: { if (existing !== undefined) return existing; const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId()); watchStores.set(conversationId, store); - ensureLoaded(conversationId); subscribeChat(conversationId); + ensureLoaded(conversationId); return store; } @@ -186,6 +213,8 @@ export function createChatRegistry(deps: { * tab strip). Only the ephemeral watch store is disposed + unsubscribed. */ function unwatchConversation(conversationId: string): void { + watchedIds.delete(conversationId); + syncChatSubscriptions(); // A tab reuses its own store — leave it (and its subscription) intact. if (chatStores.has(conversationId)) return; const store = watchStores.get(conversationId); @@ -207,6 +236,10 @@ export function createChatRegistry(deps: { refreshActiveChat, subscribeChat, unsubscribeChat, + reconnect(): void { + subscribedIds.clear(); + syncChatSubscriptions(); + }, watchConversation, unwatchConversation, get draftStore() { diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts index d97e999..9c8c25b 100644 --- a/src/app/open-conversations.svelte.ts +++ b/src/app/open-conversations.svelte.ts @@ -130,7 +130,15 @@ export function createOpenConversations(deps: { * (closed on another device), and subscribes to `active` conversations' * live streams. */ - async function fetchOpenConversations(): Promise { + let pendingFetch: Promise | undefined; + function fetchOpenConversations(): Promise { + pendingFetch ??= loadOpenConversations().finally(() => { + pendingFetch = undefined; + }); + return pendingFetch; + } + + async function loadOpenConversations(): Promise { try { const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); if (!res.ok) return; @@ -203,10 +211,14 @@ export function createOpenConversations(deps: { // Remove tabs for conversations no longer active/idle (closed elsewhere). const backendIds = new Set(data.conversations.map((c) => c.id)); for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { + if ( + !backendIds.has(tab.conversationId) && + !registry.chatStores.get(tab.conversationId)?.pendingInputs.length + ) { removeTabLocally(tab.conversationId); } } + registry.refreshActiveChat(); } catch (err) { deps.reportError( `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 33f93bb..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -270,8 +270,8 @@ function createFakeStorage(): Storage { getItem(key: string): string | null { return map.get(key) ?? null; }, - key(_index: number): string | null { - return null; + key(index: number): string | null { + return [...map.keys()][index] ?? null; }, removeItem(key: string) { map.delete(key); @@ -2711,13 +2711,14 @@ describe("conversation load-on-demand", () => { ws: WebSocketLike, count = 3, activeConversationId: string | null = "lazy-0", + workspaceCount = 1, ) { const storage = createFakeStorage(); const conversations = Array.from({ length: count }, (_, i) => ({ id: `lazy-${i}`, title: `Conversation ${i}`, model: "openai/gpt-4o", - workspaceId: "default", + workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`, status: i === 1 ? "active" : "idle", lastActivityAt: 1234, })); @@ -2763,7 +2764,7 @@ describe("conversation load-on-demand", () => { fetchImpl.mock.calls .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search) .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url)); - await vi.waitFor(() => expect(store.tabs).toHaveLength(count)); + await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount))); if (activeConversationId !== null) { await vi.waitFor(() => expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`), @@ -2785,7 +2786,7 @@ describe("conversation load-on-demand", () => { ]); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(300); + ).toHaveLength(1); expect(store.tabs[299]?.title).toBe("Conversation 299"); expect(store.conversationStatus("lazy-1")).toBe("active"); ws.feedServerMessage({ @@ -2801,6 +2802,24 @@ describe("conversation load-on-demand", () => { } }, 20_000); + it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => { + const ws = fakeSocket(); + const { store } = await setup(ws, 339, "lazy-0", 24); + try { + ws.resolveOpen(); + const subscriptions = parseSent(ws).filter( + (message) => (message as { type: string }).type === "chat.subscribe", + ); + expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]); + store.setActiveWorkspace("workspace-1"); + store.selectTab("lazy-1"); + expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" }); + expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" }); + } finally { + store.dispose(); + } + }, 20_000); + it("does not load any history while the active slot is a draft", async () => { const { store, requests } = await setup(fakeSocket(), 300, null); try { @@ -2813,13 +2832,13 @@ describe("conversation load-on-demand", () => { } }, 20_000); - it("loads a tab once, including repeated activation while its load is pending", async () => { + it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => { const { store, requests } = await setup(fakeSocket()); try { store.selectTab("lazy-2"); store.selectTab("lazy-0"); store.selectTab("lazy-2"); - await vi.waitFor(() => expect(requests()).toHaveLength(4)); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2); expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234); } finally { @@ -2827,7 +2846,7 @@ describe("conversation load-on-demand", () => { } }); - it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => { + it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => { const ws = reconnectableSocket(); const { store, requests } = await setup(ws); try { @@ -2847,7 +2866,7 @@ describe("conversation load-on-demand", () => { ).toBe(true); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(4); + ).toHaveLength(2); } finally { store.dispose(); } @@ -3129,3 +3148,53 @@ it("routes omitted replay only to the matching watch/tab without WS resend or an store.dispose(); } }); + +describe("unconfirmed first-message recovery", () => { + it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => { + const storage = createFakeStorage(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).endsWith("/conversations?status=active,idle")) + return Response.json({ conversations: [] }); + return base(input, init); + }; + const ws = fakeSocket(); + const first = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => ws, + }); + ws.resolveOpen(); + first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]); + const id = first.activeConversationId; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(first.activeChat.pendingInputs).toHaveLength(1); + first.dispose(); + const nextSocket = fakeSocket(); + const next = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => nextSocket, + }); + try { + nextSocket.resolveOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true); + expect(next.activeChat.pendingInputs).toMatchObject([ + { request: { message: "must survive" } }, + ]); + const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? ""; + nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId }); + expect(next.activeChat.pendingInputs).toEqual([]); + expect( + parseSent(nextSocket).some((message) => + ["chat.send", "chat.queue"].includes((message as { type: string }).type), + ), + ).toBe(false); + } finally { + next.dispose(); + } + }); +}); diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts new file mode 100644 index 0000000..03cdaa1 --- /dev/null +++ b/src/app/subscription-budget.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + liveConversationIds, + MAX_CHAT_SUBSCRIPTIONS, + MAX_SURFACE_SUBSCRIPTIONS, +} from "./subscription-budget"; + +describe("live subscription budget", () => { + it("reserves the focused conversation even when hundreds of viewers exist", () => { + const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`); + const selected = liveConversationIds("focused", viewers); + expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS); + expect(selected.has("focused")).toBe(true); + expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256); + }); + it("deduplicates a modal viewing the focused chat and permits drafts", () => { + expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]); + expect([...liveConversationIds(null, [])]).toEqual([]); + }); +}); diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts new file mode 100644 index 0000000..fc0ccb2 --- /dev/null +++ b/src/app/subscription-budget.ts @@ -0,0 +1,15 @@ +/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */ +export const MAX_CHAT_SUBSCRIPTIONS = 128; +export const MAX_SURFACE_SUBSCRIPTIONS = 128; + +export function liveConversationIds( + activeId: string | null, + watchedIds: Iterable, +): Set { + return new Set( + [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice( + 0, + MAX_CHAT_SUBSCRIPTIONS, + ), + ); +} diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts index 400f612..c79c8b6 100644 --- a/src/app/surface-protocol.svelte.ts +++ b/src/app/surface-protocol.svelte.ts @@ -9,6 +9,7 @@ import { subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, } from "../core/protocol"; +import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget"; /** Shared app/plugin surface operations over the single app protocol state. */ export function createSurfaceProtocol(deps: { @@ -43,7 +44,19 @@ export function createSurfaceProtocol(deps: { */ function syncSubscriptions(): void { const cid = deps.focusedConversationId(); - for (const entry of protocol.catalog) { + const catalogIds = new Set( + protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).map((e) => e.id), + ); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + } + for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) { // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id // so a conversation switch doesn't churn a redundant unsubscribe+subscribe // round trip (ui-contract@0.2.0 catalog `scope`; ABSENT = assume @@ -55,16 +68,6 @@ export function createSurfaceProtocol(deps: { deps.send(msg); } } - const catalogIds = new Set(protocol.catalog.map((e) => e.id)); - for (const id of [...protocol.subscriptions.keys()]) { - if (!catalogIds.has(id)) { - const result = protocolUnsubscribe(protocol, id); - protocol = result.state; - for (const msg of result.outgoing) { - deps.send(msg); - } - } - } receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id))); } @@ -93,6 +96,11 @@ export function createSurfaceProtocol(deps: { } function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + if ( + !protocol.subscriptions.has(surfaceId) && + protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS + ) + return; const result = protocolSubscribe( protocol, surfaceId, diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts index 5ac4f70..405e561 100644 --- a/src/app/surface-protocol.test.ts +++ b/src/app/surface-protocol.test.ts @@ -63,3 +63,34 @@ describe("conversation-scoped surface lookup", () => { expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); }); }); + +describe("surface subscription budget", () => { + it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => { + const live = new Set(); + let peak = 0; + const surfaces = createSurfaceProtocol({ + focusedConversationId: () => "chat", + send(message) { + if (message.type === "subscribe") live.add(message.surfaceId); + if (message.type === "unsubscribe") live.delete(message.surfaceId); + peak = Math.max(peak, live.size); + }, + }); + for (const prefix of ["old", "new"]) { + surfaces.handleServerMessage({ + type: "catalog", + catalog: Array.from({ length: 339 }, (_, i) => ({ + id: `${prefix}-${i}`, + region: "inline", + title: "Surface", + scope: "global" as const, + })), + }); + expect(live.size).toBe(128); + expect([...live].every((id) => id.startsWith(prefix))).toBe(true); + } + surfaces.subscribeSurfaceOp("overflow"); + expect(live.has("overflow")).toBe(false); + expect(peak).toBe(128); + }); +}); diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte index 62b9344..d132fad 100644 --- a/src/app/views/ChatPane.svelte +++ b/src/app/views/ChatPane.svelte @@ -7,7 +7,7 @@ * preservation, the ScrollToBottom affordance, and the empty-state * watermark. Keyed on the active conversation exactly like today's ChatView. */ - import { ChatView, isClaudeSubscriptionModel } from "../../features/chat"; + import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat"; import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll"; import { preserveViewportAcross, type AppViewPorts } from "../view-ports"; @@ -91,9 +91,10 @@ {cacheTimer} /> {/key} + - {#if store.activeChat.chunks.length === 0} + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0}