summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 19:39:47 +0900
committerAdam Malczewski <[email protected]>2026-10-21 19:39:47 +0900
commit7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (patch)
tree29bbfa52183b2a6639d96ad21d66806fb8ccea30
parentac43e92d174ed1f081c387944e9044643ee9efc2 (diff)
downloaddispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.tar.gz
dispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.zip
fix(chat): bound live subscriptions and preserve unconfirmed input
-rw-r--r--.dispatch/transport-contract.reference.md1553
-rw-r--r--backend-handoff.md29
-rw-r--r--src/adapters/ws/index.ts14
-rw-r--r--src/adapters/ws/logic.ts26
-rw-r--r--src/app/App.test.ts1
-rw-r--r--src/app/chat-registry.svelte.ts59
-rw-r--r--src/app/open-conversations.svelte.ts16
-rw-r--r--src/app/store.test.ts87
-rw-r--r--src/app/subscription-budget.test.ts20
-rw-r--r--src/app/subscription-budget.ts15
-rw-r--r--src/app/surface-protocol.svelte.ts30
-rw-r--r--src/app/surface-protocol.test.ts31
-rw-r--r--src/app/views/ChatPane.svelte5
-rw-r--r--src/app/ws-handlers.ts37
-rw-r--r--src/core/wire/conformance.test.ts8
-rw-r--r--src/core/wire/conformance.ts4
-rw-r--r--src/features/chat/adapter/pending-inputs.ts49
-rw-r--r--src/features/chat/delivery.test.ts146
-rw-r--r--src/features/chat/index.ts3
-rw-r--r--src/features/chat/pending-inputs.ts12
-rw-r--r--src/features/chat/store.svelte.ts104
-rw-r--r--src/features/chat/store.test.ts1
-rw-r--r--src/features/chat/ui/PendingInputs.svelte34
23 files changed, 1672 insertions, 612 deletions
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 `[email protected]` (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 — `[email protected]` 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]: <description>` 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?` `<key>/<model>` 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/<conversationId>/<uuid>.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 `[email protected]`, 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:<path>`; 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 `<credentialName>/<model>` 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 `<credentialName>/<model>` 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<Record<string, ModelMetadata>>;
+ readonly models: readonly string[];
+ readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
}
/** 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
+ * `<credential>/<model>`). 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 `<credential>/<model>` 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 `<credentialName>/<model>` 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 `<credentialName>/<model>` 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 `<credentialName>/<model>` 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:<path>` 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:<path>` 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 (`<key>/<model>`) 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 `<credentialName>/<model>` 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 `<credentialName>/<model>` 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 ([email protected]) ───────────────────
+// ─── 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 `<credentialName>/<model>` 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 (`<credentialName>/<model>`), 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 (`[email protected]`, 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<string, ChatStore>();
// Includes in-flight loads: repeated activation must not start a second load.
const loadedConversationIds = new Set<string>();
+ const loadingConversationIds = new Set<string>();
// 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<string, ChatStore>();
+ const watchedIds = new Set<string>();
+ const subscribedIds = new Set<string>();
+
+ 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<void> {
+ let pendingFetch: Promise<void> | undefined;
+ function fetchOpenConversations(): Promise<void> {
+ pendingFetch ??= loadOpenConversations().finally(() => {
+ pendingFetch = undefined;
+ });
+ return pendingFetch;
+ }
+
+ async function loadOpenConversations(): Promise<void> {
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<string>,
+): Set<string> {
+ 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 ([email protected] 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<string>();
+ 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}
+ <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} />
</div>
</div>
- {#if store.activeChat.chunks.length === 0}
+ {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center"
aria-hidden="true"
diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts
index b9adb30..e4fd916 100644
--- a/src/app/ws-handlers.ts
+++ b/src/app/ws-handlers.ts
@@ -1,8 +1,10 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
ChatQueuedMessage,
ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
@@ -55,7 +57,13 @@ export function createWsHandlers(deps: {
tileLayoutStore,
} = deps;
function handleChatMessage(
- msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage,
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
): void {
let targetId: string | undefined;
if (msg.type === "chat.delta") {
@@ -72,7 +80,13 @@ export function createWsHandlers(deps: {
}
}
- if (msg.type === "chat.queued" || msg.type === "chat.resync-required") return;
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ )
+ return;
// fallback: try all stores (chat.error without conversationId)
for (const store of registry.chatStores.values()) {
@@ -166,23 +180,8 @@ export function createWsHandlers(deps: {
// workspace too. Re-declare it so the re-pushed catalog is filtered
// (plan 06 §6.4).
deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId()));
- // Re-attach to every open conversation's turn stream. A turn that kept
- // running while we were disconnected resumes streaming (server replays it
- // from `turn-start`); one that sealed while we were gone is committed from
- // history by `resync()` (which also clears a now-stale "generating").
- // Unloaded tabs stay lazy — only their cheap live subscription is restored.
- for (const tab of tabsStore.tabs) {
- registry.subscribeChat(tab.conversationId);
- if (registry.loadedConversationIds.has(tab.conversationId)) {
- registry.chatStores.get(tab.conversationId)?.resync();
- }
- }
- // Re-attach to every MODAL watch too (a run-chat modal open across a
- // reconnect keeps streaming). Watch stores are separate from tabs.
- for (const [watchId, watchStore] of registry.watchStores) {
- registry.subscribeChat(watchId);
- if (registry.loadedConversationIds.has(watchId)) watchStore.resync();
- }
+ registry.reconnect();
+ void conversations.fetchOpenConversations();
},
onExtensionsChanged(_msg: ExtensionsChangedMessage) {
// §11 multi-client coherence: another client (or this one) mutated the
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index b3c774f..6bafda1 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -178,6 +178,12 @@ describe("classifies every WsServerMessage type", () => {
event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" },
},
{ type: "chat.error" as const, message: "e" },
+ { type: "chat.accepted" as const, conversationId: "c1", requestId: "r1" },
+ {
+ type: "chat.subscription-rejected" as const,
+ conversationId: "c1",
+ reason: "subscription-limit" as const,
+ },
{
type: "chat.queued" as const,
conversationId: "conv-1",
@@ -235,6 +241,8 @@ describe("classifies every WsServerMessage type", () => {
"error",
"chat.delta",
"chat.error",
+ "chat.accepted",
+ "chat.subscription-rejected",
"chat.queued",
"chat.resync-required",
"conversation.open",
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 3429ed0..cd53b65 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -87,6 +87,10 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "chat.delta";
case "chat.resync-required":
return "chat.resync-required";
+ case "chat.accepted":
+ return "chat.accepted";
+ case "chat.subscription-rejected":
+ return "chat.subscription-rejected";
case "chat.queued":
return "chat.queued";
case "chat.error":
diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts
new file mode 100644
index 0000000..5798fdb
--- /dev/null
+++ b/src/features/chat/adapter/pending-inputs.ts
@@ -0,0 +1,49 @@
+import type { PendingInput, PendingInputStorage } from "../pending-inputs";
+
+export function createPendingInputStorage(
+ storage: Storage,
+ conversationId: string,
+): PendingInputStorage {
+ const prefix = `dispatch.pending-inputs:${conversationId}:`;
+ let known = new Map<string, string>();
+ return {
+ load() {
+ const inputs: PendingInput[] = [];
+ const loaded = new Map<string, string>();
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key === null || !key.startsWith(prefix)) continue;
+ const raw = storage.getItem(key);
+ if (raw === null) continue;
+ const input = JSON.parse(raw);
+ if (
+ (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") ||
+ input.request.conversationId !== conversationId ||
+ typeof input.request.requestId !== "string" ||
+ key !== prefix + input.request.requestId ||
+ (input.request.type === "chat.send"
+ ? typeof input.request.message !== "string"
+ : typeof input.request.text !== "string") ||
+ (input.rejection !== undefined && typeof input.rejection !== "string")
+ )
+ throw new Error("Invalid saved input");
+ inputs.push(input);
+ loaded.set(input.request.requestId, raw);
+ }
+ known = loaded;
+ return inputs;
+ },
+ save(inputs) {
+ // Per-request keys prevent another browser tab's send/receipt from overwriting ours.
+ // Quota/security failures reach the composer BEFORE it clears its staged input.
+ const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)]));
+ for (const [id, raw] of next) {
+ if (known.get(id) !== raw) storage.setItem(prefix + id, raw);
+ }
+ for (const id of known.keys()) {
+ if (!next.has(id)) storage.removeItem(prefix + id);
+ }
+ known = next;
+ },
+ };
+}
diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts
new file mode 100644
index 0000000..99ad2d4
--- /dev/null
+++ b/src/features/chat/delivery.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi } from "vitest";
+import { createPendingInputStorage } from "./adapter/pending-inputs";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+function storage(): Storage {
+ const values = new Map<string, string>();
+ return {
+ get length() {
+ return values.size;
+ },
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => {
+ values.set(key, value);
+ },
+ removeItem: (key) => {
+ values.delete(key);
+ },
+ clear: () => values.clear(),
+ key: (index) => [...values.keys()][index] ?? null,
+ };
+}
+
+function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) {
+ const transport = createFakeTransport();
+ let id = 0;
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => `request-${++id}`,
+ pendingInputStorage: createPendingInputStorage(saved, "c"),
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync,
+ cache: createFakeCache().impl,
+ });
+ return { store, transport, saved };
+}
+
+const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }];
+
+describe("input delivery", () => {
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("saves %s input through refresh without automatically retransmitting", (method) => {
+ const f = fixture();
+ f.store[method]("keep me", images);
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.dispose();
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]);
+ expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me");
+ expect(reloaded.transport.sent).toEqual([]);
+ expect(reloaded.transport.sentQueue).toEqual([]);
+ reloaded.store.dispose();
+ });
+
+ it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" },
+ });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" });
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" });
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("retains explicitly rejected input and images for recovery until discarded", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.error",
+ conversationId: "c",
+ requestId: "request-1",
+ message: "disk full",
+ });
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([
+ { rejection: "disk full", request: { message: "mine", images } },
+ ]);
+ reloaded.store.discardPendingInput("request-1");
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ reloaded.store.dispose();
+ });
+
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("does not clear or transmit %s input if saving fails", (method) => {
+ const saved = storage();
+ saved.setItem = () => {
+ throw new Error("quota exceeded");
+ };
+ const f = fixture(saved);
+ expect(() => f.store[method]("mine", images)).toThrow("quota exceeded");
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(f.store.chunks).toEqual([]);
+ expect(f.transport.sent).toEqual([]);
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => {
+ let resolve!: (value: { turns: [] }) => void;
+ const first = {
+ promise: new Promise<{ turns: [] }>((done) => {
+ resolve = done;
+ }),
+ resolve: (value: { turns: [] }) => resolve(value),
+ };
+ const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] });
+ const f = fixture(storage(), metricsSync);
+ for (let i = 0; i < 100; i++) f.store.resync();
+ expect(metricsSync).toHaveBeenCalledTimes(1);
+ first.resolve({ turns: [] });
+ await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2));
+ f.store.dispose();
+ });
+});
+
+it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => {
+ const saved = storage();
+ const first = createPendingInputStorage(saved, "c");
+ const second = createPendingInputStorage(saved, "c");
+ first.load();
+ second.load();
+ const input = (requestId: string) => ({
+ request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId },
+ });
+ first.save([input("first")]);
+ second.save([input("second")]);
+ first.save([]);
+ expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]);
+});
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index fec2cdb..41cb06f 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -6,6 +6,7 @@ export type {
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
+export { createPendingInputStorage } from "./adapter/pending-inputs";
export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select";
export {
CLAUDE_SUBSCRIPTION_KEY,
@@ -22,6 +23,7 @@ export {
openCodeNumber,
providerKeysWithNamedAgents,
} from "./model-select";
+export type { PendingInput, PendingInputStorage } from "./pending-inputs";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
EffortOption,
@@ -53,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte";
export type { ComposerStatus } from "./ui/Composer.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as PendingInputs } from "./ui/PendingInputs.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts
new file mode 100644
index 0000000..d7fb0aa
--- /dev/null
+++ b/src/features/chat/pending-inputs.ts
@@ -0,0 +1,12 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+
+/** A saved copy, never automatically retransmitted: requestId is not idempotent. */
+export interface PendingInput {
+ readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string };
+ readonly rejection?: string;
+}
+
+export interface PendingInputStorage {
+ load(): readonly PendingInput[];
+ save(inputs: readonly PendingInput[]): void;
+}
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index bb0b218..dacbe06 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,4 +1,5 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
ChatQueueCancelMessage,
@@ -6,6 +7,7 @@ import type {
ChatQueueMessage,
ChatResyncRequiredMessage,
ChatSendMessage,
+ ChatSubscriptionRejectedMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
@@ -43,9 +45,11 @@ import {
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
import type { NamedAgentSelectionSnapshot } from "../named-agents";
+import type { PendingInput, PendingInputStorage } from "./pending-inputs";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
+ readonly pendingInputStorage?: PendingInputStorage;
readonly conversationId: string;
/** Injected unique client-local correlation token for each new send. */
readonly createRequestId: () => string;
@@ -98,6 +102,8 @@ export interface ChatStoreDependencies {
}
export interface ChatStore {
+ readonly pendingInputs: readonly PendingInput[];
+ discardPendingInput(requestId: string): void;
readonly messages: readonly ChatMessage[];
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
@@ -156,7 +162,13 @@ export interface ChatStore {
*/
noteLastActivity(at: number): void;
handleDelta(
- msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage,
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
): void;
/**
* Send a user message (start a turn via `chat.send`). Optimistically echoes
@@ -232,6 +244,14 @@ export interface ChatStore {
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
+ let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []);
+ function savePendingInputs(next: readonly PendingInput[]): void {
+ deps.pendingInputStorage?.save(next);
+ pendingInputs = next;
+ }
+ function confirmInput(requestId: string): void {
+ savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId));
+ }
let transcript = $state<TranscriptState>(initialState());
let metrics = $state<MetricsState>(initialMetricsState());
let _pendingSync = $state(false);
@@ -507,10 +527,30 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
}
- async function syncMetrics(): Promise<void> {
+ let pendingMetrics: Promise<void> | undefined;
+ let rerunMetrics = false;
+ function syncMetrics(): Promise<void> {
+ if (pendingMetrics !== undefined) {
+ rerunMetrics = true;
+ return pendingMetrics;
+ }
+ pendingMetrics = (async () => {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ rerunMetrics = false;
+ await fetchMetrics();
+ if (!rerunMetrics || disposed) break;
+ }
+ })().finally(() => {
+ pendingMetrics = undefined;
+ });
+ return pendingMetrics;
+ }
+
+ async function fetchMetrics(): Promise<void> {
if (disposed) return;
try {
const res = await deps.metricsSync(deps.conversationId);
+ if (disposed) return;
metrics = applyDurableMetrics(metrics, res.turns);
} catch (err) {
// Metrics fetch failure must not block history sync or throw;
@@ -580,6 +620,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
return {
+ get pendingInputs() {
+ return pendingInputs;
+ },
+ discardPendingInput: confirmInput,
get messages(): readonly ChatMessage[] {
return selectMessages(transcript);
},
@@ -626,9 +670,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
},
handleDelta(
- msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage,
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
): void {
if (disposed) return;
+ if (msg.type === "chat.accepted") {
+ if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId);
+ return;
+ }
+ if (msg.type === "chat.subscription-rejected") {
+ if (msg.conversationId === deps.conversationId) {
+ _error =
+ "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below.";
+ void syncTail();
+ }
+ return;
+ }
if (msg.type === "chat.resync-required") {
if (msg.conversationId !== deps.conversationId) return;
flushEvents();
@@ -656,6 +718,16 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return;
}
_error = msg.message;
+ if (msg.requestId !== undefined) {
+ savePendingInputs(
+ pendingInputs.map((input) =>
+ input.request.requestId === msg.requestId
+ ? { ...input, rejection: msg.message }
+ : input,
+ ),
+ );
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
return;
}
if (msg.type === "chat.queued") {
@@ -663,7 +735,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
// A queued live opening may already have confirmed the echoed text.
// Fold it first; turn-start alone must not protect still-local artifacts.
flushEvents();
- if (msg.requestId !== undefined) transcript = removeLocalEcho(transcript, msg.requestId);
+ if (msg.requestId !== undefined) {
+ confirmInput(msg.requestId);
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
return;
}
if (msg.event.conversationId !== deps.conversationId) {
@@ -685,9 +760,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
// Sending ends the wait since the last turn: stamp it on that turn.
cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics));
const requestId = deps.createRequestId();
- transcript = appendUserMessage(transcript, text, images, requestId);
- maybeTrim();
- const msg: ChatSendMessage = {
+ const msg: ChatSendMessage & { requestId: string } = {
type: "chat.send",
requestId,
conversationId: deps.conversationId,
@@ -697,19 +770,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been sent", error);
+ throw error;
+ }
+ transcript = appendUserMessage(transcript, text, images, requestId);
+ maybeTrim();
deps.transport.send(msg);
},
queueMessage(text: string, images?: readonly ImageInput[]): void {
const trimmed = text.trim();
if (trimmed.length === 0 && !images?.length) return;
- const msg: ChatQueueMessage = {
+ const msg: ChatQueueMessage & { requestId: string } = {
type: "chat.queue",
+ requestId: deps.createRequestId(),
conversationId: deps.conversationId,
text: trimmed,
...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been queued", error);
+ throw error;
+ }
deps.transport.send(msg);
},
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index c24592d..5040e67 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -290,6 +290,7 @@ describe("createChatStore", () => {
expect(transport.sentQueue).toEqual([
{
type: "chat.queue",
+ requestId: expect.any(String),
conversationId: CONV_ID,
workspaceId: "images-workspace",
text: text.trim(),
diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte
new file mode 100644
index 0000000..cbaf35f
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import type { PendingInput } from "../pending-inputs";
+
+ let {
+ inputs,
+ onDiscard,
+ }: {
+ inputs: readonly PendingInput[];
+ onDiscard: (requestId: string) => void;
+ } = $props();
+</script>
+
+{#each inputs as input (input.request.requestId)}
+ <div class="m-3 rounded border border-warning p-3 text-sm" role="status">
+ <p>
+ {input.rejection
+ ? `Message rejected: ${input.rejection}`
+ : "Delivery unconfirmed. Your input is saved on this device."}
+ </p>
+ <p>Check the conversation history before sending again to avoid a duplicate.</p>
+ <textarea
+ class="textarea textarea-bordered mt-2 w-full"
+ aria-label="Saved message"
+ readonly
+ value={input.request.type === "chat.send" ? input.request.message : input.request.text}
+ ></textarea>
+ {#each input.request.images ?? [] as image}
+ <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" />
+ {/each}
+ <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)}
+ >Discard saved copy</button
+ >
+ </div>
+{/each}