diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 243 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 213 | ||||
| -rw-r--r-- | AGENTS.md | 10 | ||||
| -rw-r--r-- | GLOSSARY.md | 1 | ||||
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | ROADMAP.md | 1 | ||||
| -rw-r--r-- | backend-handoff-workspaces-reply.md | 216 | ||||
| -rw-r--r-- | backend-handoff-workspaces.md | 157 | ||||
| -rw-r--r-- | backend-handoff.md | 279 | ||||
| -rw-r--r-- | bun.lock | 20 | ||||
| -rw-r--r-- | notes/assumptions-log.md | 58 | ||||
| -rw-r--r-- | package.json | 10 | ||||
| -rwxr-xr-x | scripts/fix-dist-perms.sh | 25 | ||||
| -rw-r--r-- | scripts/live-probe-provider-retry.ts | 188 |
14 files changed, 1284 insertions, 153 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 02b48a0..8c6023a 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,3 +1,29 @@ +# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules) + +> 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. +> +> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status). Regenerate whenever +> it changes. +> +> **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`. + /** * Transport contract — the typed description of Dispatch's client–server API * (HTTP + WebSocket). @@ -28,6 +54,8 @@ import type { ReasoningEffort, StoredChunk, TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; export type { @@ -40,6 +68,8 @@ export type { StepMetrics, StoredChunk, TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; /** @@ -80,6 +110,13 @@ export interface ChatRequest { * 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; } /** @@ -170,6 +207,14 @@ export interface ConversationMetricsResponse { 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; +} + /** The aggregation window for `GET /metrics/throughput`. */ export type ThroughputPeriod = "day" | "week" | "month"; @@ -221,9 +266,19 @@ export interface CwdResponse { readonly cwd: string | null; } -/** Body of `PUT /conversations/:id/cwd`. */ +/** + * Body of `PUT /conversations/:id/cwd`. + * + * When `workspaceId` is provided, the conversation is assigned to that + * workspace BEFORE the cwd is persisted — so a subsequent + * `GET /conversations/:id/lsp` resolves a relative cwd against the + * workspace's `defaultCwd` (not the server default). Omit for unchanged + * workspace assignment (the conversation keeps its current workspace, or + * `"default"` if none). + */ export interface SetCwdRequest { readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -248,6 +303,29 @@ export interface SetReasoningEffortRequest { readonly reasoningEffort: ReasoningEffort; } +// ─── Per-conversation model persistence ─────────────────────────────────────── + +/** + * 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). + */ +export interface ModelResponse { + readonly conversationId: string; + readonly model: string | null; +} + +/** + * Body of `PUT /conversations/:id/model` — persists the conversation's sticky + * model selection (used for every later turn that does not carry a per-turn + * `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). + */ +export interface SetModelRequest { + readonly model: string | null; +} + // ─── Conversation close (explicit tab close) ────────────────────────────────── /** @@ -270,6 +348,57 @@ export interface CloseConversationResponse { readonly abortedTurn: boolean; } +// ─── System prompt template ─────────────────────────────────────────────────── + +/** + * Response of `GET /system-prompt` — the current global system prompt template. + * + * The template is a text string with variable placeholders (`[type:name]`) and + * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first + * turn or compaction), variables are resolved against the conversation's cwd + * and system state. The resolved system prompt is persisted per conversation + * 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; +} + +/** + * Body of `PUT /system-prompt` — set the global system prompt template. + * + * Changing the template does NOT affect existing conversations until they are + * compacted (the persisted resolved system prompt is stable). New + * conversations use the new template on their first turn. + */ +export interface SetSystemPromptTemplateRequest { + readonly template: string; +} + +/** + * One available variable for the system prompt template, as reported by + * `GET /system-prompt/variables` so the frontend can render the variable + * 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; +} + +/** Response of `GET /system-prompt/variables`. */ +export interface SystemPromptVariablesResponse { + readonly variables: readonly SystemPromptVariable[]; +} + // ─── Message queue (steering) ───────────────────────────────────────────────── /** @@ -289,6 +418,11 @@ export interface CloseConversationResponse { */ 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; } /** @@ -324,17 +458,60 @@ export interface LspServerInfo { 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 conversation's persisted cwd, or null if unset (then `servers` is empty). */ + /** + * 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 ────────────────────────────────────────────────────── + +export type McpServerState = "connecting" | "connected" | "error" | "disconnected"; + +/** 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; +} + +/** 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[]; +} + /** * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING * request for a conversation (e.g. a frontend "warm now" button, or fast tests @@ -474,6 +651,11 @@ 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; } /** @@ -509,6 +691,12 @@ export type 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; } /** @@ -520,6 +708,12 @@ 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; } /** @@ -606,3 +800,48 @@ export interface CompactPercentResponse { export interface SetCompactPercentRequest { readonly threshold: number; } + +// ─── Workspaces ─────────────────────────────────────────────────────────────── + +/** + * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All + * fields are optional and only applied when the workspace is first created; + * 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; +} + +/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ +export interface WorkspaceResponse extends Workspace {} + +/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ +export interface WorkspaceListResponse { + readonly workspaces: readonly WorkspaceEntry[]; +} + +/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ +export interface SetWorkspaceTitleRequest { + readonly title: string; +} + +/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ +export interface SetWorkspaceDefaultCwdRequest { + readonly defaultCwd: string | null; +} + +/** + * Response of `DELETE /workspaces/:id`. All conversations in the workspace + * are closed (status → "closed") and reassigned to "default", then the + * 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; +} +``` + diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 44b0fe7..b7003ec 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,98 +4,13 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (compaction). Regenerate -> whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces). Regenerate whenever `@dispatch/wire` changes. > -> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):** -> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`, -> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it -> becomes the conversation's first system message after compaction). -> -> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):** -> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle -> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` = -> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are -> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on -> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged` -> (see `[email protected]`). -> -> **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):** -> adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt), -> returned by `GET /conversations` (the list endpoint, see `[email protected]`). -> -> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):** -> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING, -> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`, -> see `[email protected]`); it is delivered mid-turn as **steering** — injected at the next -> tool-result boundary so the model sees it alongside the tool results and can adjust course. If the -> turn ends with a non-empty queue (no tool call fired), the queue is carried into a NEW turn as its -> opening prompt (no `steering` event — the new turn's `user-message` covers it). -> -> Adds: -> - **`QueuedMessage`** (`{ id, text, queuedAt }`) — a message held in the queue (stable id for UI -> keying + dedup). -> - **`QueuePayload`** (`{ messages: QueuedMessage[] }`) — the payload of the message-queue -> extension's per-conversation `custom` surface field (`rendererId: "message-queue"`). Carried on -> the SURFACE channel (NOT the chat stream) — the queue is control/state. Empty `messages` = empty -> queue. See `transport-contract.reference.md` for the surface + the enqueue op. -> - **`TurnSteeringEvent`** (`{ type: "steering"; conversationId; turnId; text }`) — a NEW -> `AgentEvent` union member, emitted on the chat stream when the kernel drains a non-empty queue -> at a tool-result boundary. Render `text` as a USER bubble in the transcript (positioned after -> the tool-result it followed); the queue surface separately clears on drain. One event per drain; -> `text` is the combined text of all drained messages. Late-join safe (buffered into the in-flight -> turn's event buffer, mirroring `user-message`). Carry-to-new-turn does NOT emit `steering`. -> ADDITIVE to the union — if you have an exhaustive `AgentEvent` switch, add a `steering` case. -> -> **2026-06-12 delta (reasoning-effort handoff — package bumped `0.6.1` → `0.7.0`, ADDITIVE):** -> adds the **`ReasoningEffort`** type — the per-request thinking-depth ladder -> `"low" | "medium" | "high" | "xhigh" | "max"`. Provider-agnostic; the Anthropic provider maps -> levels to extended-thinking token budgets (low 4096 · medium 10240 · high 16384 · xhigh 32768 · -> max 65536); providers without a thinking knob ignore it. Resolution is SERVER-owned (do not -> re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value -> (`GET`/`PUT /conversations/:id/reasoning-effort`, see `[email protected]`) → default -> `"high"`. Higher levels mean longer runs of `reasoning-delta` events before the first text delta. -> See the `ReasoningEffort` definition below. -> -> **2026-06-12 delta (CR-5 history windowing — package bumped `0.6.0` → `0.6.1`, DOC-ONLY):** the -> per-conversation `seq` numbering is now a WRITTEN CONTRACTUAL GUARANTEE on `StoredChunk`: -> **1-based, monotonic, gap-free** — a conversation's first chunk is always `seq === 1` and -> numbering never skips. A client holding only a windowed suffix of the log derives "older chunks -> exist server-side" purely from `oldestLoaded.seq > 1` (no `earliestSeq`/`hasOlder` field exists). -> -> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a -> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`) -> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of -> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join -> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt -> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that -> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below. -> -> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL -> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the -> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate -> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current -> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field -> doc-comments on `TurnMetrics`/`TurnDoneEvent` below. -> -> **0.3.0 changes (token + timing metrics):** -> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted): -> `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW -> `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step -> generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the -> runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent` -> gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across -> steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time). -> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics` -> — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by -> `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME -> `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it -> for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire. -> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL -> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`. -> Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read -> `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is -> UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`). +> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds +> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required +> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A +> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that +> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`. ```ts /** @@ -299,8 +214,8 @@ export interface StepMetrics { * Durable per-turn metrics for a completed (sealed) turn — the persisted, * replayable counterpart of the live `done` event's aggregate `usage` + * `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all - * steps; `steps` carries each step's `StepMetrics` in step order. Stored by - * `conversation-store` keyed by `turnId` and served by + * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per + * turn by `conversation-store` (returned in turn-append order) and served by * `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried * on every `AgentEvent`, the join key to the live stream.) */ @@ -373,6 +288,7 @@ export type AgentEvent = | TurnUsageEvent | TurnStepCompleteEvent | TurnErrorEvent + | TurnProviderRetryEvent | TurnDoneEvent | TurnSealedEvent | TurnSteeringEvent; @@ -393,13 +309,15 @@ export interface TurnStartEvent { /** * The user prompt that opened this turn, surfaced INTO the turn's outward event - * stream so a WATCHER (subscribed but not the sender) can render the prompt - * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE - * as the FIRST event of the turn (before `turn-start`); buffered + replayed to - * every subscriber (live + late-join). The sender echoes its own prompt - * optimistically, so DE-DUP against that (by text); a pure watcher renders it - * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it - * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.) + * stream. The user message is persisted only when the turn seals (atomically with + * the assistant reply), so without this event a client that is merely WATCHING a + * conversation (subscribed but not the sender) has no source for the prompt text + * mid-turn — it would see the streaming reply with no preceding user bubble until + * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it + * is buffered and replayed to every subscriber — live and late-join — exactly like + * the rest of the turn. The sender already echoes its own prompt optimistically, so + * a consumer should de-dup against that (e.g. by text); a pure watcher renders it + * directly. Carries the raw prompt `text` (the same text passed to the provider). */ export interface TurnInputEvent { readonly type: "user-message"; @@ -527,6 +445,31 @@ export interface TurnErrorEvent { readonly code?: string; } +/** + * A retryable provider error is being retried with backoff. Emitted once per + * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded — + * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT + * persisted into the model's message history (it never pollutes the prompt). + * + * When the retry budget is exhausted, the existing `error` event is emitted and + * the turn seals — so the final failure is still a persisted error. `attempt` is + * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep + * before that retry fires. + */ +export interface TurnProviderRetryEvent { + readonly type: "provider-retry"; + readonly conversationId: string; + readonly turnId: string; + /** 0-based: this is the Nth retry about to happen. */ + readonly attempt: number; + /** ms the client should expect to wait before the retry fires. */ + readonly delayMs: number; + /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ + readonly message: string; + /** The HTTP code when known (e.g. "429"). */ + readonly code?: string; +} + /** The turn has completed (model finished generating). */ export interface TurnDoneEvent { readonly type: "done"; @@ -545,11 +488,11 @@ export interface TurnDoneEvent { */ readonly usage?: Usage; /** - * **Context size** — tokens the conversation occupies right now: the turn's - * FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM - * round-trip plus that round-trip's output). This is the "tokens in context" - * figure a client renders as the chat's current context usage, and a client - * treats the LATEST turn's value as the live total. + * **Context size** — the number of tokens the conversation now occupies: this + * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full + * prompt sent into the last LLM round-trip plus that round-trip's output). This + * is the "tokens in context" figure a client renders as the chat's current + * context usage, and a client treats the LATEST turn's value as the live total. * * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's * `inputTokens`, which overcounts a multi-step / tool-calling turn because every @@ -598,10 +541,11 @@ export interface TurnSteeringEvent { // ─── Conversation metadata ─────────────────────────────────────────────────── /** - * The per-conversation lifecycle status. `active` = a turn is generating; - * `idle` = exists, not generating; `closed` = dismissed (hidden from the tab - * bar, not deleted). Transitions are backend-owned and pushed via the - * `conversation.statusChanged` WS message (see `transport-contract`). + * The lifecycle status of a conversation, used for tab persistence across + * devices. `active` = an agent is currently generating; `idle` = exists but not + * generating; `closed` = user dismissed the tab (hidden from the tab bar, not + * deleted). New conversations start as `idle`; transitions to `active` on + * turn-start, back to `idle` on turn done/error, and to `closed` on user close. */ export type ConversationStatus = "active" | "idle" | "closed"; @@ -609,7 +553,8 @@ export type ConversationStatus = "active" | "idle" | "closed"; * Metadata for a conversation, returned by `GET /conversations` (the list * endpoint). The title defaults to the first user message (truncated) and can * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write; - * `lastActivityAt` is updated on every append. + * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle + * for cross-device persistence. */ export interface ConversationMeta { readonly id: string; @@ -617,7 +562,17 @@ export interface ConversationMeta { readonly lastActivityAt: number; readonly title: string; readonly status: ConversationStatus; - /** Points to the archive conversation with full pre-compaction history. */ + /** + * The workspace this conversation belongs to. Always present; reads as + * `"default"` for legacy conversations that were never explicitly assigned. + * Conversations created with no `workspaceId` default to `"default"`. + */ + readonly workspaceId: string; + /** + * Set on a compacted conversation: points to the archive conversation ID + * that holds the full pre-compaction history. Absent on conversations + * that have never been compacted. + */ readonly compactedFrom?: string; } @@ -627,6 +582,8 @@ export interface ConversationMeta { * Result of a compaction operation. `summary` is the text the model produced; * `messagesKept` is how many recent messages were retained after the summary; * `messagesSummarized` is how many old messages were replaced by the summary. + * `newConversationId` is the ID of the new conversation that holds the full + * pre-compaction history (non-destructive — the original history is preserved). */ export interface CompactionResult { readonly summary: string; @@ -634,4 +591,38 @@ export interface CompactionResult { readonly messagesSummarized: number; readonly messagesKept: number; } + +// ─── Workspaces ────────────────────────────────────────────────────────────── + +/** + * A named, URL-driven grouping of conversations that owns a default cwd. + * Every conversation belongs to exactly one workspace; conversations that + * haven't set their own per-conversation cwd inherit `defaultCwd`. + * + * Workspaces are backend-owned (so cross-device just works): the workspace + * entity and each conversation's `workspaceId` live server-side. The + * `"default"` workspace is always present and non-deletable; conversations + * created with no `workspaceId` are assigned to `"default"`. + */ +export interface Workspace { + /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ + readonly id: string; + /** Display title (editable). Defaults to `id` on creation. */ + readonly title: string; + /** The workspace's default cwd, or `null` (fall through to server default). */ + readonly defaultCwd: string | null; + /** Epoch-ms when the workspace was first created. */ + readonly createdAt: number; + /** Epoch-ms of the most recent conversation activity in this workspace. */ + readonly lastActivityAt: number; +} + +/** + * A workspace entry in the list response (`GET /workspaces`) — a `Workspace` + * plus a conversation count. + */ +export interface WorkspaceEntry extends Workspace { + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; +} ``` @@ -3,7 +3,7 @@ > Loaded every session — the single source of truth for working in this repo: the > build constitution (code rules) + the workflow. Non-obvious, project-specific rules > only — if a fresh frontier model could infer it from the code, it is NOT here (P6). -> Full design + rationale: `../arch-rewrite/notes/frontend-design.md` (and the +> Full design + rationale: `../dispatch-backend/notes/frontend-design.md` (and the > backend's `notes/restructure-plan.md` §1 for P1–P8). > > **You are the single agent for this repo.** You plan, author the cross-unit @@ -13,7 +13,7 @@ ## What this is The **web frontend** for Dispatch — a SEPARATE repo from the backend -(`../arch-rewrite`). It is a **thin shell + pure feature libraries + a surface +(`../dispatch-backend`). It is a **thin shell + pure feature libraries + a surface host**, NOT a default-SvelteKit ball of mud. It consumes the backend's typed contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket. The app is a COMPOSITION of feature modules + surfaces, assembled at the composition root @@ -37,7 +37,7 @@ src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history .dispatch/ mirrored backend contracts (*.reference.md) + rules/ reports/ (gitignored) ``` -Backend (SEPARATE repo, contracts only): `../arch-rewrite` — consume +Backend (SEPARATE repo, contracts only): `../dispatch-backend` — consume `@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it. ## The non-negotiable rules @@ -91,7 +91,7 @@ effects backed by a shared global (`fake-indexeddb`, `localStorage`) to catch cr pollution. After a slice that touches the wire or browser effects, run a LIVE probe (below). ## Backend seam (cross-repo) -The backend is `../arch-rewrite` (separate repo; `lsp references` does NOT span the +The backend is `../dispatch-backend` (separate repo; `lsp references` does NOT span the boundary). You consume `@dispatch/ui-contract` + the wire/transport types as pinned `file:` deps. **Read the in-repo mirrors `.dispatch/*.reference.md`, never `node_modules/@dispatch/*`** (they symlink out of the repo); regenerate the relevant mirror whenever a contract changes. @@ -143,7 +143,7 @@ live view (subscribe/reconnect + the user prompt on the event stream), and the c show-earlier server backfill; `hasOlder` from the 1-based gap-free seq contract), and the reasoning-effort selector (Model view, under the provider/model dropdowns; sticky per-conversation `GET`/`PUT /reasoning-effort`, `null` ⇒ "high (default)"). Plan in -`../arch-rewrite/notes/frontend-design.md` §10. +`../dispatch-backend/notes/frontend-design.md` §10. ## Reports Optionally record a finished milestone in `reports/<name>.md` (gitignored): what you built, diff --git a/GLOSSARY.md b/GLOSSARY.md index e350ffa..fe4360c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -43,3 +43,4 @@ | **chat limit** | The max LOADED chunks per conversation (default 256; persisted at localStorage `dispatch.chatLimit`, settable via the sidebar's Settings view) before the oldest quarter is unloaded. Counts **chunks** (committed + provisional + accumulating). Policy in `core/chunks/trim.ts`. | chunk limit, message limit, history limit | | **unload** | Drop the oldest COMMITTED chunks from the in-memory transcript (and DOM) past the **chat limit** — in BULK (`ceil(limit/4)` per pass, deferred while the reader is scrolled up), never one-per-delta (old Dispatch's scroll-jump bug). Purely local: the IndexedDB cache and the server keep everything; `TranscriptState.hiddenBeforeSeq` is the watermark. Distinct from the conversation-cache's cross-conversation **eviction**. | evict (reserved for the cross-conversation cache), prune, drop | | **show earlier** | The affordance at the top of a transcript with unloaded history ("Show earlier messages"): pages one unload-unit back in — local cache first, then the server (CR-5 `?beforeSeq=&limit=`) when the cache doesn't reach far enough back — preserving the reader's scroll position. Offered whenever the loaded window starts above seq 1 (the [email protected] 1-based gap-free seq contract). | load more, pagination | +| **workspaces** | A URL-driven grouping of conversations that owns a **default cwd**. The root path `/` lists workspaces; `/<id>` opens one (visiting a nonexistent id creates it — create-on-miss). Each conversation belongs to exactly one workspace; the always-present `"default"` workspace is the fallback for unassigned/legacy conversations. Tabs are scoped per workspace (filtered client-side). Backend-owned (`[email protected]` `Workspace`); a conversation's cwd inherits `workspace.defaultCwd` when its own is unset. Distinct from the per-conversation cwd+LSP module (`features/cwd-lsp`, formerly `features/workspace`). | project, space | @@ -1,6 +1,6 @@ # Dispatch Web -The **web frontend** for [Dispatch](../arch-rewrite) — a separate repo built to the same +The **web frontend** for [Dispatch](../dispatch-backend) — a separate repo built to the same methodology (thin shell + pure feature libraries + a backend-driven *surface* host). It consumes the backend's typed contracts over HTTP + a WebSocket and ships no business logic the backend doesn't expose. @@ -17,17 +17,17 @@ doesn't expose. - [Bun](https://bun.sh) (v1.3+). - **The backend repo as a sibling directory** — this repo links `@dispatch/ui-contract` from - `../arch-rewrite` via a `file:` dependency: + `../dispatch-backend` via a `file:` dependency: ``` dispatch/ - arch-rewrite/ # the backend (Dispatch server) + dispatch-backend/ # the backend (Dispatch server) dispatch-web/ # this repo ``` -- The **backend server running** for surfaces to appear (see `../arch-rewrite/README.md`). +- The **backend server running** for surfaces to appear (see `../dispatch-backend/README.md`). ```sh cd dispatch-web -bun install # links @dispatch/ui-contract from ../arch-rewrite +bun install # links @dispatch/ui-contract from ../dispatch-backend ``` --- @@ -36,7 +36,7 @@ bun install # links @dispatch/ui-contract from ../arch-rewrite ```sh # 1) start the backend (sibling repo) — HTTP :24203 + surface WS :24205 -cd ../arch-rewrite && bun run dev +cd ../dispatch-backend && bun run dev # 2) start this dev server — Vite on :24204 cd ../dispatch-web && bun run dev @@ -46,7 +46,7 @@ Open **http://localhost:24204**. You'll see the surface catalog (e.g. "Loaded Ex frontend connects to the backend's surface WebSocket at `ws://localhost:24205` (override with `VITE_WS_URL`). -> **Tip — run both at once with live reload:** the backend repo ships `../arch-rewrite/bin/up` +> **Tip — run both at once with live reload:** the backend repo ships `../dispatch-backend/bin/up` > (also `bun run dev:all` there) which starts the backend (`bun --watch`) + this dev server > (Vite HMR) together; **Ctrl-C stops both**. @@ -99,5 +99,5 @@ bun run check # biome (.ts/.js; .svelte correctness is svelte-check's job ## Documentation -- **Design + plan:** `../arch-rewrite/notes/frontend-design.md` +- **Design + plan:** `../dispatch-backend/notes/frontend-design.md` - **Build rules + workflow:** `AGENTS.md` · **Vocabulary:** `GLOSSARY.md` @@ -19,6 +19,7 @@ - **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag. - **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations. - **Conversation compaction** — "Compaction" sidebar view with manual "Compact now" button (`POST /conversations/:id/compact`) + auto-compact threshold input (`GET`/`PUT /conversations/:id/compact-threshold`); `conversation.compacted` WS handler reloads history. +- **Workspaces** — URL-driven conversation grouping with a backend-owned default cwd (`[email protected]`/`[email protected]`). Routing: `/` lists workspaces (create-on-visit + delete); `/<id>` opens one (tabs scoped to it, existing tabs migrate to `"default"`). New conversations are stamped with the active workspace on `chat.send`/`chat.queue`. Workspace CRUD via `GET`/`PUT`/`DELETE /workspaces`. *Pending: CwdField explicit-vs-inherited display + clear-to-inherit (`DELETE /conversations/:id/cwd`).* ## Next up diff --git a/backend-handoff-workspaces-reply.md b/backend-handoff-workspaces-reply.md new file mode 100644 index 0000000..2ddfaf5 --- /dev/null +++ b/backend-handoff-workspaces-reply.md @@ -0,0 +1,216 @@ +# Backend handoff — Workspaces (backend → FE) — courier doc + +> **From:** arch-rewrite orchestrator · **To:** dispatch-web orchestrator · **Courier:** the user. +> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes +> the backend will implement. The FE should re-pin `@dispatch/wire` and +> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`. + +## Version bumps + +| Package | From | To | Notes | +|---|---|---|---| +| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` | +| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops | +| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** | + +--- + +## 1. Final types — `@dispatch/[email protected]` + +```ts +/** + * A named, URL-driven grouping of conversations that owns a default cwd. + * Every conversation belongs to exactly one workspace; conversations that + * haven't set their own per-conversation cwd inherit `defaultCwd`. + */ +export interface Workspace { + /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ + readonly id: string; + /** Display title (editable). Defaults to `id` on creation. */ + readonly title: string; + /** The workspace's default cwd, or `null` (fall through to server default). */ + readonly defaultCwd: string | null; + /** Epoch-ms when the workspace was first created. */ + readonly createdAt: number; + /** Epoch-ms of the most recent conversation activity in this workspace. */ + readonly lastActivityAt: number; +} + +/** + * A workspace entry in the list response — a `Workspace` plus a conversation count. + */ +export interface WorkspaceEntry extends Workspace { + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; +} +``` + +`ConversationMeta` gains a required `workspaceId`: + +```ts +export interface ConversationMeta { + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + /** Always present; "default" for legacy/unspecified conversations. */ + readonly workspaceId: string; + readonly compactedFrom?: string; +} +``` + +--- + +## 2. Final types — `@dispatch/[email protected]` + +### Additive fields on existing request types + +```ts +export interface ChatRequest { + readonly conversationId?: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; + /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} + +export interface QueueRequest { + readonly text: string; + /** Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} + +export interface ChatQueueMessage { + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} +``` + +### Workspace endpoint types + +```ts +/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */ +export interface EnsureWorkspaceRequest { + /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */ + 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. */ +export interface WorkspaceListResponse { + readonly workspaces: readonly WorkspaceEntry[]; +} + +/** Body of `PUT /workspaces/:id/title`. */ +export interface SetWorkspaceTitleRequest { + readonly title: string; +} + +/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */ +export interface SetWorkspaceDefaultCwdRequest { + readonly defaultCwd: string | null; +} + +/** Response of `DELETE /workspaces/:id`. */ +export interface DeleteWorkspaceResponse { + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; +} +``` + +--- + +## 3. Final endpoint list + +| Method & Path | Body | Returns | Notes | +|---|---|---|---| +| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. | +| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. | +| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. | +| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). | +| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. | +| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. | +| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. | +| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). | + +### Existing endpoints (semantic note, no type change) + +- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default). +- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd. + +--- + +## 4. cwd resolution (backend-owned) + +``` +effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation +if (effectiveCwd == null) { + workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback + workspace = conversationStore.getWorkspace(workspaceId) + effectiveCwd = workspace?.defaultCwd ?? null +} +if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today +``` + +- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit). +- `GET /conversations/:id/lsp` → effective cwd. +- Turn start (`runTurn` / `warm`) → effective cwd. + +--- + +## 5. `DELETE /workspaces/:id` semantics + +1. Close all conversations in that workspace (set `status = "closed"`). +2. Reassign their `workspaceId` to `"default"` (so no dangling reference). +3. Delete the workspace entity. +4. Return `{ workspaceId, closedCount }`. +5. `DELETE /workspaces/default` → HTTP 409. + +Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`). + +--- + +## 6. Workspace lifecycle / auto-creation + +- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`). +- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is. +- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable. +- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list. +- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes. +- **Compaction:** post-compaction conversations inherit the original's `workspaceId`. + +--- + +## 7. Answers to FE open questions (Q1–Q8) + +| # | Decision | +|---|---| +| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. | +| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). | +| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. | +| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). | +| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. | +| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. | +| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. | +| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. | + +--- + +## 8. Gaps resolved (from FE handoff §3) + +1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted. +2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is. +3. **lastActivityAt on title/default-cwd changes** → no. +4. **LSP cwd field** → returns effective cwd. +5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`. diff --git a/backend-handoff-workspaces.md b/backend-handoff-workspaces.md new file mode 100644 index 0000000..dc27c0e --- /dev/null +++ b/backend-handoff-workspaces.md @@ -0,0 +1,157 @@ +# Backend handoff — Workspaces (FE → backend) — courier doc + +> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. +> Companion to the living `backend-handoff.md` (new open ask, §2). 2026-06-23. +> `lsp references` does NOT span the two repos, so this is the cross-repo channel. +> FE is current on `[email protected]` / `[email protected]` / `[email protected]` (686 tests green). +> **This is a design ask, not a bug report.** Please review, analyze, propose an implementation plan, +> and surface any gaps / questions back. The FE will adapt to whatever shapes you land on. + +--- + +## 1. What we're building (the FE product behavior) + +A **workspace** is a named, URL-driven grouping of **conversations** that owns a **default cwd**. +Every conversation belongs to exactly one workspace; a workspace's default cwd is used by its +conversations that haven't set their own per-conversation cwd. + +Routing (net-new on the FE — there is no router today): +- **`/`** (no path) — home: lists all workspaces (title, slug, last activity). The user can **delete** a + workspace here. +- **`/<workspace-id>`** — opens that workspace and loads its tabs. +- **Visiting `/<id>` when the workspace doesn't exist → it is created at that point** (title defaults to + the id; the user can rename the title later). The **id (the URL slug) is immutable**; the **title is + display-only and editable**. + +Workspaces are **backend-owned** (so cross-device just works): the workspace entity (title, default-cwd), +and each conversation's `workspaceId`, live server-side. The FE is a thin client over your contracts and +ships no business logic you don't expose (per the FE's constitution). + +### Naming note (FE-internal, NO backend impact) +The FE already has a `features/workspace` module — but it is the **per-conversation cwd-field + LSP-status** +module (it consumes `GET`/`PUT /conversations/:id/cwd` + `GET /conversations/:id/lsp`), NOT this concept. +We are renaming that module to `features/cwd-lsp` so the new feature takes `workspaces`. Flagged only so +the backend isn't confused by the FE module name; it has **zero contract impact**. + +--- + +## 2. What the FE needs from the backend (the proposed contract surface) + +Everything below is the FE's *requirement + proposed shape*. **You own the final shapes** — pick the +verbs/field-names/type-homes that fit the backend, and ask back where a requirement is unclear or conflicts +with your design. + +### 2.1 Workspace entity + conversation assignment +- A `Workspace` type (your call: `@dispatch/wire` alongside `ConversationMeta`, or `@dispatch/transport-contract` + with the endpoints): `{ id: string (the URL slug, immutable), title: string (defaults to id on creation, + editable), defaultCwd: string | null, createdAt: number, lastActivityAt: number }`. +- `ConversationMeta` gains an additive **`workspaceId: string`**. Conversations created with no workspace + ⇒ `"default"` (the fallback). Legacy conversations (no `workspaceId` persisted) should be treated as + `"default"` — ideally **no backfill needed**; `ConversationMeta.workspaceId` reads as `"default"` for them. + +### 2.2 Conversation creation carries the workspace +Conversation-creating ops gain an optional additive `workspaceId` (default `"default"`) so the backend +stamps the conversation's workspace at creation: +- `ChatRequest` (HTTP `POST /chat`) and `ChatSendMessage` (WS `chat.send`). +- The queue ops that can start a turn: `POST /conversations/:id/queue` (`QueueRequest`) and `chat.queue` (WS). + +Note the FE mints `conversationId`s client-side and sends them on `chat.send`; the `workspaceId` travels +alongside. **Existing invariant preserved:** `chat.send` still **omits `cwd`** (sends `undefined`) — the +backend resolves the effective cwd from the workspace default (see 2.4). + +### 2.3 Workspace endpoints (your call on exact verbs/shapes) +- **`GET /workspaces`** — list all (for the `/` home). Sorted by `lastActivityAt` desc. Enough for a picker + (id, title, timestamps; a conversation count would be nice but optional). +- **`GET /workspaces/:id`** — **create-on-miss**: if absent, create it (`title = id`, `defaultCwd = null`) + and return it; if present, return it. Idempotent. This is the route-enter action when the user visits + `/<id>`. (We note GET-with-side-effects; you already lazy-spawn LSP on `GET /conversations/:id/lsp`, so + there's precedent — but feel free to propose `PUT`/upsert instead; see open Q4.) +- **`PUT /workspaces/:id/title`** (body `{ title }`) — rename (display only; id/URL unchanged). +- **`PUT /workspaces/:id/default-cwd`** (body `{ defaultCwd }` or `{ cwd }`) — set the workspace default cwd. + `null`/empty = "no default; fall through to the server default." +- **`DELETE /workspaces/:id`** — delete a workspace. **Open question (Q1): what happens to its conversations.** + +### 2.4 cwd resolution (backend-owned — the FE does NOT re-implement) +At turn time, resolve cwd as: **explicit conversation cwd (`GET /conversations/:id/cwd`) +> `workspace.defaultCwd` > server default.** +- `GET /conversations/:id/cwd` should keep returning the **explicit** conversation cwd (`null` = + "inheriting the workspace default"), so the FE can render "inherited from workspace X" vs "explicit." + The FE reads `workspace.defaultCwd` separately to show the inherited value. +- `GET /conversations/:id/lsp` must root at the **effective** cwd (conversation cwd ?? `workspace.defaultCwd`), + so LSP spawns against the workspace default when the conversation hasn't set its own. + +### 2.5 The `default` workspace +- Always present, **non-deletable**, id `"default"`. It is the fallback for unassigned conversations. The FE + navigates to `/default` for it. **Please confirm** the backend guarantees its existence on boot and rejects + `DELETE /workspaces/default`. + +### 2.6 Conversation list filtered by workspace +- `GET /conversations` gains an additive **`?workspaceId=<id>`** filter (composable with the existing + `?status=` and `?q=`). Used by the FE to restore a workspace's active/idle conversations as tabs on a new + device (the existing cross-device tab-restore path, now workspace-scoped). The FE is **not** building a + full conversation-browser sidebar in this iteration — just the restore query. + +--- + +## 3. Open questions for the backend to analyze + decide (FE will adapt) + +These are implementation decisions the FE defers to the backend. Please analyze each, pick an approach, +and ask back wherever the FE's requirement is unclear or conflicts with your design: + +1. **Delete a workspace → fate of its conversations.** Reassign them to `"default"`? Block deletion while + non-empty? Delete them? The FE's only hard requirement: deleting a workspace must **not orphan/hide** + conversations (they must remain reachable). You decide semantics; the FE renders whatever you return. +2. **"Clear to inherit" for conversation cwd.** With inheritance, a user may want to unset an explicit + conversation cwd to fall back to the workspace default. Today `PUT /conversations/:id/cwd` with `""` ⇒ + `400 { error }`. Do you want to (a) treat `null`/empty as "inherit" (relax the 400), (b) add + `DELETE /conversations/:id/cwd`, or (c) defer the affordance (v1: a conversation cwd, once set, can be + changed but not unset)? The FE is fine with any. +3. **Workspace lifecycle over WS.** For live cross-device refresh of the home list (a workspace + created/renamed/deleted on device A appears on device B's `/`), should you broadcast + `workspace.created` / `workspace.updated` / `workspace.deleted` (mirroring `conversation.statusChanged`), + or is the FE's fetch-on-mount + manual refresh sufficient for v1? The FE can ship either way; WS push is + a nice-to-have, not a blocker. +4. **create-on-miss vs. explicit create.** Is `GET /workspaces/:id` create-on-miss acceptable, or would you + prefer a distinct `POST /workspaces` / `PUT /workspaces/:id` (upsert) the FE calls on route-enter? The FE + just needs "visit URL ⇒ workspace exists" in one round-trip. +5. **Slug validation.** The id is a URL path segment. FE proposes URL-safe `[a-z0-9-]`, lowercase, + length-bounded. Please define the canonical validation + whether you normalize or reject. Also: should + creation reject slugs colliding with reserved names (`default`)? +6. **Where do the types live?** `Workspace` in `@dispatch/wire` (like `ConversationMeta`) or + `@dispatch/transport-contract` (with the endpoints)? And the request/response shapes for the workspace + endpoints — your call; mirror the existing `TitleResponse`/`SetTitleRequest` style if it fits. +7. **Cross-device tab set (confirm the FE's model).** The *open-tab set* stays **per-device** (FE + localStorage); cross-device sync covers workspaces + conversations + assignments, and a fresh device + restores a workspace's active/idle conversations as tabs via + `GET /conversations?workspaceId=<id>&status=active,idle`. Does the backend need to do anything beyond + exposing `workspaceId` on `ConversationMeta` + the `?workspaceId=` filter? (FE assumes **no**.) +8. **Compaction interaction.** `conversation.compacted` yields a `newConversationId` (see + `ConversationCompactedMessage`/`CompactionResult`). Should the new (post-compaction) conversation inherit + the original's `workspaceId`? The FE assumes **yes**. + +--- + +## 4. How the FE will consume it (so you can shape the contract) + +- Route enter `/<id>` → `GET /workspaces/:id` (create-on-miss) → render the workspace view + its tabs. +- `/` home → `GET /workspaces` → list; delete via `DELETE /workspaces/:id`; (maybe) a "new workspace" input + that navigates to `/<slug>` (creation via the visit). +- New conversation in workspace W → `chat.send` / `POST /chat` with `workspaceId: W.id` (cwd still omitted). +- The CwdField (the renamed `cwd-lsp` module) shows explicit cwd (`GET /conversations/:id/cwd`) vs inherited + (`workspace.defaultCwd`). +- On workspace entry, restore active/idle conversations as tabs: + `GET /conversations?workspaceId=<id>&status=active,idle`. + +--- + +## 5. Priority / sequencing + +Not a hard blocker — the FE can begin FE-only scaffolding (the routing adapter + a workspace-view shell +with provisional local types) in parallel, but the **cross-repo types must land before the FE wires real +data.** Please bump `wire` / `transport-contract` / `ui-contract` as needed and note the changes in the +reply so the FE re-pins the `file:` deps + re-mirrors the relevant `.dispatch/*.reference.md`. + +**Asks back to you, the backend:** (a) a review of the above for feasibility/fit; (b) a concrete +implementation plan (final types, endpoints, resolution mechanics); (c) answers/decisions on Q1–Q8; and +(d) any gaps or questions YOU have about the FE's requirements. The FE will then build against the landed +contract. diff --git a/backend-handoff.md b/backend-handoff.md index 2768493..c82d9cb 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -2,42 +2,52 @@ > **Purpose:** the single rolling document the FE orchestrator keeps current so the user can hand off > the whole FE↔backend seam at any time — on completion OR at a roadblock. Updated continuously. -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. +> **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-22 (context window + percentage-based compact consumed). -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** 686 tests green. -**Open asks: NONE.** All CRs resolved (CR-1 through CR-6) + context-window + compact-percent -handoff consumed._ +_Last updated: 2026-06-24 (`transport-contract` re-pinned 0.20.0 → 0.22.0; `file:` dep paths +fixed `../arch-rewrite` → `../dispatch-backend`; MCP server-status endpoint/types consumed + backend shipped +`GET /conversations/:id/mcp` (CR-12 resolved); transient `provider-retry` AgentEvent consumed (additive to +`[email protected]`, no version bump))._ +**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** +(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). +Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11 +(per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three. +The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to +`[email protected]` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c. +FE re-pinned + re-mirrored `transport-contract`; `selectModel` persists to +`PUT /conversations/:id/model` and conversation focus recalls the persisted model via `GET /conversations/:id/model`. +FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`) — see §2b. --- ## 1. Pinned backend contracts (consumed by the FE) | Package | Used for | |---|---| | `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` | -| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | +| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` | +| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): `POST /chat` (NDJSON) · `GET /models` · `GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` (CR-5 windowing) · `GET /conversations/:id/metrics` · `GET`/`PUT /conversations/:id/cwd` · `GET`/`PUT /conversations/:id/reasoning-effort` (sticky thinking-depth; `null` ⇒ default `high`) · -`GET /conversations/:id/lsp` · `POST /chat/warm` · `POST /conversations/:id/close` (explicit +`GET`/`PUT /conversations/:id/model` (sticky per-conversation model persistence) · +`GET /conversations/:id/lsp` · `GET /conversations/:id/mcp` (MCP server status; mirrors `/lsp`) · `POST /chat/warm` · `POST /conversations/:id/close` (explicit tab-close: abort turn + stop/disable warming) · `POST /conversations/:id/queue` (enqueue steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` · WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) · WS `chat.queue` (enqueue steering; fire-and-forget — surface updates on success) · -WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab) · -WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`). +WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab; carries `workspaceId`) · +WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`). Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` (regenerate on any contract bump; all current as of `[email protected]` / -`[email protected]` / `[email protected]`). +`[email protected]` / `[email protected]`). ### FE invariants to keep (don't regress) @@ -48,11 +58,133 @@ Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-con `StoredChunk`). The FE derives `hasOlder = oldestLoaded.seq > 1`. - **Warming opt-in is NOT re-hydrated across a backend restart** — a conversation reads disabled until toggled again (fail-safe). Backend offered boot hydration if it becomes a product need. +- **`PUT /conversations/:id/cwd` sends the active `workspaceId`** (CR-8) so a relative cwd set on a + new/draft tab is assigned to the workspace BEFORE persisting — the subsequent `GET /lsp` then + resolves it against `workspace.defaultCwd`. The store reads `activeWorkspaceId` with `untrack` at + call time (never reactive inside the async). `chat.send` still omits `cwd` (the persisted cwd wins). +- **`conversation.open` and `conversation.statusChanged` WS broadcasts carry `workspaceId`** so tabs opened + by the CLI `--open` flag (or by a cross-device `active` status change) are stamped with the conversation's + actual workspace instead of the viewer's current workspace. The FE ignores any broadcast missing `workspaceId` + (parser returns null) — acceptable because the backend contract is updated in lockstep. --- ## 2. Open asks FOR THE BACKEND +### CR-9 — `system:os` variable: include WSL detection + Linux distro → **OPEN** + +The `system:os` system-prompt variable (resolved by the backend at construction time) should +return a richer OS string: + +1. **WSL detection** — when running under Windows Subsystem for Linux, the resolved `system:os` + value should indicate WSL (e.g. `"Linux (WSL)"` or `"WSL2"` rather than just `"Linux"`). + Detection: check for the presence of `/proc/sys/fs/binfmt_misc/WSLInterop` or + `Microsoft` in `/proc/version`, or the `WSL_DISTRO_NAME` environment variable. +2. **Linux distro** — on Linux, include the distribution name (e.g. `"Ubuntu 22.04"` or + `"Ubuntu"` rather than just `"Linux"`). Source: `/etc/os-release` (`PRETTY_NAME` or + `NAME`/`VERSION_ID`). + +No wire/transport-contract/ui-contract change needed — this is a backend behavior change in +how the `system:os` variable is resolved (the type shape is unchanged: it's still a `string`). +The FE is unaffected (it only inserts `[system:os]` into the template; the backend resolves it). + +### CR-7 — Workspace cwd fallthrough bug + relative-path resolution → **RESOLVED ✅ (backend shipped; FE code unchanged)** + +Fixed backend-side (reply from arch-rewrite agent ab13). **No wire/transport-contract/ui-contract +bumps needed; FE does NOT need a re-pin or re-mirror.** + +**What was fixed:** +1. Workspace `defaultCwd` now applies when the conversation has no explicit per-conversation cwd. +2. A per-turn `cwd` (or persisted `cwd`) that is **relative** is now resolved against the workspace + `defaultCwd`, not raw → falls through to `process.cwd()`. +3. `DELETE /conversations/:id/cwd` now actually clears the persisted cwd (was a no-op stub). +4. New-conversation timing: first turn assigns the workspace before resolving cwd, so a relative + per-turn cwd on a brand-new conversation resolves against the correct workspace. + +**Resolution algorithm (backend-owned):** +``` +workspaceCwd = workspace?.defaultCwd ?? null +conversationCwd = persisted per-conversation cwd OR per-turn cwd from chat.send (null if omitted) +if (conversationCwd == null) effectiveCwd = workspaceCwd ?? serverDefaultCwd // process.cwd() +else if (conversationCwd[0] === "/") effectiveCwd = conversationCwd +else effectiveCwd = path.resolve(workspaceCwd ?? serverDefaultCwd, conversationCwd) +``` + +**FE impact:** none — the FE already sends `workspaceId` and **omits `cwd`** on `chat.send` +(`src/features/chat/store.svelte.ts`). The persisted cwd is set separately via `PUT /conversations/:id/cwd`, +and the backend resolves it at turn start. `GET /conversations/:id/cwd` still returns the raw explicit +value (e.g., `"gameplay"`) for the CwdField; `GET /conversations/:id/lsp` returns the resolved effective +cwd. + +**Optional future FE enhancement:** Add a "Clear" button to CwdField that calls +`DELETE /conversations/:id/cwd`, letting the user reset a conversation to inherit the workspace +`defaultCwd`. Not required for the fix. + +--- + +### CR-10 — `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts → **RESOLVED ✅ (`[email protected]`; FE consumed)** + +**Bug:** When summoning an agent via the CLI `--open --workspace <id>` flag, the tab opened across +ALL workspaces instead of just the one it was assigned to. The backend knew the conversation's +workspace but dropped it from the broadcast — the `ConversationOpenMessage` and +`ConversationStatusChangedMessage` WS messages carried only `conversationId`. The FE then fell back +to stamping the tab with `activeWorkspaceId` (the viewer's current workspace), so the tab appeared in +every open browser tab's workspace view. + +**Backend fix (shipped):** Additive `workspaceId: string` on both broadcast messages +(`[email protected]`). The backend resolves the conversation's persisted workspace +(`"default"` fallback) at broadcast time — not the per-turn start option — and includes it in the +`conversation.open` and `conversation.statusChanged` fan-out. + +**FE fix (consumed):** +- WS parser (`src/adapters/ws/logic.ts`): parse + require `workspaceId` on both message types. +- `openConversation()` (`src/app/store.svelte.ts`): signature changed to + `(conversationId, workspaceId)`; the tab is stamped with the message's `workspaceId`, not + `activeWorkspaceId`. The `onConversationOpen` and `onConversationStatusChanged` handlers pass + `msg.workspaceId` through. +- Re-mirrored `.dispatch/transport-contract.reference.md`. +- Tests updated: `logic.test.ts`, `index.test.ts`, `conformance.test.ts`. + +**Note:** The FE parser now rejects `conversation.open` / `conversation.statusChanged` messages +missing `workspaceId` (returns null). This is acceptable because the backend contract is updated in +lockstep; a mixed-version setup (old backend + new FE) would silently drop those broadcasts. + +--- + +### CR-11 — Per-conversation model persistence → **RESOLVED ✅ (`[email protected]`; FE consumed)** + +**Backend (shipped):** +- `[email protected]` adds `ModelResponse` and `SetModelRequest`. +- New endpoints: + - `GET /conversations/:id/model` returns `{ conversationId, model: string | null }`. + - `PUT /conversations/:id/model` with body `{ model: string | null }` persists or clears the + per-conversation sticky model selection. +- The backend resolves the model per turn: explicit `ChatRequest.model` override wins, else persisted + model for the conversation, else the server default. + +**FE (consumed):** +- Imported `ModelResponse` + `SetModelRequest`; re-mirrored `.dispatch/transport-contract.reference.md`. +- Added `refreshModel()` (`src/app/store.svelte.ts`) — fetches via `GET /conversations/:id/model` on every + focus change (tab switch, workspace switch, boot, reconnect) and updates `activeModel`, the active tab's + stored model, and the active chat store's model when a non-null model is returned. +- Updated `selectModel(model)` to persist the choice via `PUT /conversations/:id/model` when a real + conversation tab is active; drafts still only update session-local state. +- Tests added (`src/app/store.test.ts`): model selection triggers a `PUT /model` with the right body, + and a persisted model is recalled when focusing a new conversation. + +--- + +### Workspaces — backend-owned conversation grouping with a default cwd → **RESOLVED ✅ (backend shipped; FE build in progress)** + +A **workspace** is a URL-driven (`/<id>`) grouping of conversations that owns a default cwd (used by its +conversations that haven't set their own). Backend-owned so cross-device just works. The backend shipped +the finalized contract (`[email protected]`/`[email protected]`): `Workspace`/`WorkspaceEntry` types, +`workspaceId` on `ConversationMeta` + `ChatRequest`/`QueueRequest`/`ChatQueueMessage`, workspace endpoints +(`GET /workspaces`, `PUT`/`GET /workspaces/:id`, `PUT .../title`, `PUT .../default-cwd`, +`DELETE /workspaces/:id`), `?workspaceId=` on `GET /conversations`, and `DELETE /conversations/:id/cwd` +(clear-to-inherit). Q1–Q8 decisions + full shapes in `backend-handoff-workspaces-reply.md`. FE re-pinned + +re-mirrored; FE feature build in progress. + ### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending) The backend now persists chunks **incrementally at step boundaries** during generation: @@ -82,6 +214,9 @@ the turn seals and `syncTail` fetches everything. | CR | Summary | Status | |---|---|---| +| CR-12 | `GET /conversations/:id/mcp` MCP server-status endpoint (mirrors `/lsp`) | ✅ `[email protected]`; backend shipped; FE consumed + verified (3 LSP-shape diffs handled) | +| CR-10 | `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts | ✅ `[email protected]`; FE consumed | +| CR-7 | Workspace cwd fallthrough bug + relative-path resolution | ✅ resolved (backend-only) | | CR-1 | Loaded Extensions as a true table (`rendererId: "table"`) | ✅ shipped + consumed | | CR-2 | catalog `scope` flag (`"global"` / `"conversation"`) | ✅ `[email protected]` | | CR-3 | `user-message` event (watcher sees user prompt mid-turn) | ✅ `[email protected]` | @@ -91,6 +226,126 @@ the turn seals and `syncTail` fetches everything. --- +## 2b. MCP server status slice → **RESOLVED ✅ (backend shipped CR-12; FE verified)** + +Consumes the backend's `GET /conversations/:id/mcp` endpoint, mirroring `GET /conversations/:id/lsp` +exactly. Contract types in `[email protected]`: `McpServerState` +(`"connecting" | "connected" | "error" | "disconnected"`), `McpServerInfo` +(`{ id, state, error?, toolCount, configSource? }`), `McpStatusResponse` +(`{ conversationId, cwd: string|null, servers: McpServerInfo[] }`). Full backend handoff: +`../dispatch-backend/frontend-mcp-status-handoff.md`; shape/behavior details: +`../dispatch-backend/reports/transport-http-mcp.md`. + +**Backend (shipped CR-12):** endpoint behaves identically to `/lsp` — no persisted cwd → +`{ cwd: null, servers: [] }` (HTTP 200, empty); MCP extension not loaded → +`503 { error: "MCP service not available" }`. The FE handles both gracefully (empty/no-cwd/503 paths +never crash the renderer; the 503 `error` string surfaces in red). + +**FE (DONE + verified against the 3 LSP-shape differences the backend flagged):** +- Re-pinned `transport-contract` 0.20.0 → 0.22.0; re-mirrored `.dispatch/transport-contract.reference.md` + (added the MCP section + the previously-missing `configSource` on `LspServerInfo`). +- New feature library `src/features/mcp/`: pure `logic/view-model.ts` (`viewMcpServer`/`viewMcpServers`/ + `summarizeMcpServers`, state→badge/label/busy mapping), `ui/McpStatusView.svelte` (mirrors + `LspStatusView`'s structure — refresh button, loading, summary, server list), `index.ts` + (`McpStatusView`/`manifest`/types). 9 view-model tests green. +- `AppStore.mcpStatus()` (`src/app/store.svelte.ts`) — mirrors `lspStatus()`: normalizes the untyped + body at the network seam (`servers` guaranteed an array), returns `McpResult | null`. +- Wired into `src/app/App.svelte`: `"mcp"` view kind, `loadMcpStatus` adapter, `McpStatusView` in the + `viewContent` snippet (re-mounts per conversation via `{#key store.currentConversationId}`). + +**Three shape differences from LSP — verified handled, do NOT regress:** +1. **Fields differ.** `McpServerInfo` has only `{ id, state, error?, toolCount, configSource? }` — NO + `name`/`root`/`extensions`. The MCP row markup (`McpStatusView.svelte`) renders `id`/state badge/ + `toolCount`/`error`/`configSource` only; it does NOT reference any LSP-only field. `McpServerView` + is a distinct type from `LspServerView` (the latter carries `name`/`root`/`extensionsLabel`). Do not + reuse the LSP row component verbatim for MCP. +2. **Enum differs.** `McpServerState = "connecting" | "connected" | "error" | "disconnected"` — a + DIFFERENT enum from LSP's `"connected" | "starting" | "error" | "not-started"`. `viewMcpServer`'s + switch handles all four MCP cases (exhaustive vs the contract): `connected`→success, `connecting`→ + warning+busy(spinner), `disconnected`→neutral (NOT busy — a stable idle state), `error`→error. Do + not share the LSP badge mapping. +3. **`configSource?` is currently always absent** on this path (the `McpServerStatus` source doesn't + carry it) but the wire type leaves it optional. The FE renders it defensively: view-model coerces + `server.configSource ?? null`; the template guards `{#if server.configSource}` with an empty-`<span>` + fallback, so absence renders nothing. Verified correct for the current always-absent reality. + +**Live probe:** NOT run — the backend was not reachable on `:24203` at verify time (the backend is the +user's process; never booted headless). The unit suite (775 tests, incl. 9 mcp) is green; a live probe +against a running backend remains a nice-to-have for the actual network seam (it's a plain HTTP GET, +no WS). Start the backend and run `bun scripts/live-probe.ts` (or just open the MCP Servers sidebar view) +to confirm end-to-end. + +--- + +## 2c. Transient `provider-retry` AgentEvent → **CONSUMED ✅ (backend shipped; FE wired + tested)** + +The backend now retries retryable provider errors (e.g. "server overloaded" HTTP 429/5xx) with a stepped +backoff (5s→10s→30s→60s→5m→10m→15m→30m→repeat, up to an 8h budget). Each scheduled retry emits a NEW +**transient** `AgentEvent`: `provider-retry`. The FE renders it as a **yellow warning system-message +bubble**; the actual model reply still streams normally after a retry succeeds. Contract types are in +`[email protected]` (additive — the type was added to the existing version, no bump; the FE's `file:` dep picks +it up automatically, no re-pin needed). `TurnProviderRetryEvent`: +`{ type: "provider-retry", conversationId, turnId, attempt (0-based), delayMs, message, code? }`. + +**FE (DONE + verified):** +- Re-mirrored `.dispatch/wire.reference.md` (added `TurnProviderRetryEvent` to the `AgentEvent` union + + its interface). The FE already resolved the new type via the `file:` symlink (no re-pin). +- `src/core/chunks/types.ts`: added `providerRetry: TurnProviderRetryEvent | null` to `TranscriptState` + (mirrors the `generating` UI-indicator pattern — event-stream-derived state that is NOT a chunk). +- `src/core/chunks/reducer.ts`: `foldEvent` SETS the banner on a `provider-retry` (coalescing — latest + attempt+delay replaces previous → a single updating banner) and CLEARS it when content resumes + (`text-delta`/`reasoning-delta`/`tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), + or a new turn starts (`turn-start`); metadata events (`status`/`usage`/`step-complete`/`tool-output`) + leave it untouched. The clearing is centralized via a `RETRY_CLEARING_EVENTS` Set in a thin `foldEvent` + wrapper over the renamed inner `reduceEvent`. `clearGenerating` (WS reconnect) also drops a stale banner. +- `src/core/wire/conformance.ts` + `.test.ts`: added the `provider-retry` case to the exhaustiveness guard + and bumped the variant count 14 → 15 (the `satisfies never` guard is what flagged the new variant). +- `src/core/chunks/selectors.ts` (`selectProviderRetry`) + new `retry-banner.ts` view-model + (`viewProviderRetry` → `{ attemptLabel: "Retry #N", delayLabel: "5s"/"30m", message, code }`, + `formatRetryDelay`), exported from `chunks/index.ts` + re-exported from `features/chat/index.ts`. +- `src/features/chat/store.svelte.ts`: `providerRetry` getter + `ChatStore` interface field (mirrors + `generating`). +- `src/features/chat/ui/ChatView.svelte`: new `providerRetry` prop → renders a DaisyUI `alert alert-warning` + yellow bubble at the end of the transcript (where the reply would appear), with `⚠ Retry #N — retrying in + {delay}…`, a `code` badge, and the endpoint error verbatim. Re-mounted per conversation. +- `src/app/App.svelte`: passes `providerRetry={store.activeChat.providerRetry}`. +- 19 new tests (reducer: set/coalesce/clear-on-resume/clear-on-turn-end/clear-on-new-turn/clear-on-reconnect/ + not-a-chunk/metadata-preserves; retry-banner: delay formatting + view-model). 775 tests green. + +**Transient / never-persisted guarantee (the critical invariant):** `provider-retry` is NEVER a `Chunk` +(it's not in the `Chunk.type` union — `assertChunkExhaustive` is unchanged). It lives only in +`TranscriptState.providerRetry`, set/cleared by `foldEvent`. It never enters `committed` (seq'd history) or +`provisional`, so it can NEVER pollute the model's prompt or be sent back as a message. On a reload/replay of +past turns it is NOT replayed (only committed seq'd chunks are history) — `providerRetry` starts null and is +set only when a NEW `provider-retry` arrives. On a WS reconnect mid-turn, `clearGenerating` drops any stale +banner (past retries aren't replayed). If the 8h budget exhausts, the existing terminal `error` `AgentEvent` +fires and seals the turn — rendered as the existing error state (the banner is cleared by the `error` case). + +**Note on `step-complete` timing:** when retries occur, the `step-complete` event's `genTotalMs` includes +the retry-sleep time (backend-side, cosmetic). The FE surfaces per-step timing unchanged — no action needed. + +**Open follow-up (optional, not blocking):** the "countdown" is a STATIC label derived from `delayMs` +("retrying in 5s…"), matching the backend's examples — NOT a live ticking timer (which would be a component +effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be added later as a Svelte +`$effect`/`setInterval` in ChatView if desired, but the static label is accurate and keeps the component thin. + +**Live probe (run, backend up):** `scripts/live-probe-provider-retry.ts` — 8/8 checks passed: +- REGRESSION: a real text turn through the REAL WS socket + the updated `foldEvent` sealed cleanly and + `providerRetry` stayed NULL throughout (no spurious banner; the `reduceEvent` wrapper didn't break streaming). + (The repo-wide `scripts/live-probe.ts` also still passes 23/23 — text/tool/metrics/CR-5 all green with the + updated reducer.) +- PARSER+REDUCER SEAM: a synthetic `provider-retry` `chat.delta` JSON frame through the REAL + `parseServerMessage` is ACCEPTED (not rejected as unknown) → `foldEvent` SETS `providerRetry` (attempt/ + delayMs/code correct) and adds NO chunk → a 2nd coalesces → a subsequent `text-delta` CLEARS it and the + reply lands as a chunk. This is the JSON-parse boundary the unit tests skip (they pass constructed events). +- NOT exercised live: the actual banner rendering in the browser (a real `provider-retry` from an overloaded + provider can't be forced from a probe — it needs a human at the page with a 429'ing provider). The full + data path (wire parse → reducer → state) IS verified live; only the Svelte render of the yellow bubble + remains a human-confirm (open the chat, trigger an overloaded provider, confirm the yellow "⚠ Retry #N — + retrying in 5s…" banner appears, updates per attempt, and clears when the reply streams). + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns @@ -5,9 +5,9 @@ "": { "name": "dispatch-web", "dependencies": { - "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract", + "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", + "@dispatch/wire": "file:../dispatch-backend/packages/wire", "dompurify": "^3.4.5", "highlight.js": "^11.11.1", "marked": "^18.0.4", @@ -34,8 +34,8 @@ }, }, "overrides": { - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", + "@dispatch/wire": "file:../dispatch-backend/packages/wire", }, "packages": { "@adobe/css-tools": ["@adobe/[email protected]", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -76,11 +76,11 @@ "@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], - "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../arch-rewrite/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }], + "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../dispatch-backend/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }], - "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}], + "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}], - "@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}], + "@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -558,9 +558,9 @@ "zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}], + "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}], - "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}], + "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md new file mode 100644 index 0000000..ebf2ff8 --- /dev/null +++ b/notes/assumptions-log.md @@ -0,0 +1,58 @@ +# Assumptions log (dispatch-web) + +> Recorded while working autonomously (user away). Raise these when the user returns. + +## 2026-06-24 — CR-10: workspaceId on conversation.open / statusChanged + +1. **Contract version already at 0.19.0 in the backend repo.** The `file:` dep in `package.json` + points to `../arch-rewrite/packages/transport-contract`, which was already at `0.19.0` when the + handoff arrived. No `package.json` version string needed changing — `bun install` re-synced the + symlink. If the backend repo is later rolled back, the FE will fail to compile (the new required + `workspaceId` field won't exist on the type). + +2. **The `dist/` directory is owned by root** (likely from a previous root-run build). `bun run build` + fails with `EACCES` when Vite tries to empty `dist/assets`. This is an environment issue, not a + code issue — I verified the build succeeds with `--outDir dist-tmp` (a fresh directory I own, then + removed). The user may want to `sudo chown -R tradam:tradam dist/` or remove it. + +3. **Parser now rejects broadcasts missing `workspaceId`.** The WS parser (`logic.ts`) returns `null` + for `conversation.open` / `conversation.statusChanged` messages that lack a string `workspaceId`. + This is a breaking change for a mixed-version setup (old backend + new FE) — those broadcasts + would be silently dropped. Assumed acceptable because the backend contract is updated in lockstep. + +4. **Tab is opened but not focused when the conversation's workspace differs from the active one.** + The FE creates the tab (stamped with the correct `workspaceId`) but does NOT navigate to that + workspace or switch the active tab. The tab is hidden by the `tabs` getter filter + (`t.workspaceId === activeWorkspaceId`) until the user navigates to the correct workspace. This + matches the original "open without switching" behavior of the `conversation.open` handler. If the + product wants auto-navigation to the conversation's workspace on `--open`, that's a separate FE + product decision. + +5. **CR-10 numbering.** The prior open ask was CR-9 (still open). I assigned this fix CR-10 in the + handoff doc for tracking. If the backend used a different CR number, adjust. + +## 2026-06-24 — CR-11: Per-conversation model persistence + +1. **`refreshModel()` is called on every focus change** (boot, tab switch, workspace switch, + reconnect) mirroring `refreshCwd` / `refreshReasoningEffort` / `refreshCompactPercent`. For a + draft conversation the `GET /conversations/:id/model` request will 404 (the draft id isn't + persisted yet); `res.ok` is false so it's a silent no-op. This matches how the other refresh + functions behave for drafts. + +2. **`refreshModel` only applies a non-empty string model.** The backend returns `model: null` when + never set; the FE leaves `activeModel` unchanged in that case (falls back to the boot default or + the last-known tab model). This avoids resetting the selector to `null`/`undefined` and crashing + `ModelSelector`'s `splitModelName`. An empty string is also ignored defensively. + +3. **`selectModel` persists only for real conversation tabs, not drafts.** A draft has no persisted + conversation id yet; the first `chat.send` will carry `model` (the chat store still sends it), + and the backend persists it on turn start. Drafts update session-local state only — same as the + pre-change behavior. + +4. **No "clear model" UI affordance added.** The backend supports `PUT /model` with `{ model: null }`, + but the FE model selector has no "reset to default" button. This is a future product decision; the + persistence path is ready if/when the UI adds it. + +5. **No `model` field on `ConversationMeta`.** Following the precedent of `cwd` and `reasoningEffort` + (fetched via dedicated endpoints, not on the list response). The FE fetches model on focus, not + from `fetchOpenConversations`. diff --git a/package.json b/package.json index acef44b..dbd57c8 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,17 @@ "check:fix": "biome check --write ." }, "dependencies": { - "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract", + "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", + "@dispatch/wire": "file:../dispatch-backend/packages/wire", "dompurify": "^3.4.5", "highlight.js": "^11.11.1", "marked": "^18.0.4", "marked-highlight": "^2.2.4" }, "overrides": { - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire" + "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", + "@dispatch/wire": "file:../dispatch-backend/packages/wire" }, "devDependencies": { "@biomejs/biome": "^2.4.16", diff --git a/scripts/fix-dist-perms.sh b/scripts/fix-dist-perms.sh new file mode 100755 index 0000000..471cbdf --- /dev/null +++ b/scripts/fix-dist-perms.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fix ownership of dist/ so Vite can clean + rebuild it. +# The dist/assets/ dir was created as root (likely a Docker build) and Vite +# can't rmSync it as a non-root user → EACCES on `bun run build`. +# +# Usage: sudo ./scripts/fix-dist-perms.sh +set -euo pipefail + +DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist" + +if [ ! -d "$DIST_DIR" ]; then + echo "No dist/ directory found — nothing to fix." + exit 0 +fi + +OWNER="$(stat -c '%U:%G' "$DIST_DIR")" +echo "dist/ is currently owned by: $OWNER" + +if [ "$OWNER" = "root:root" ]; then + echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..." +fi + +# chown the whole dist/ tree to the same owner as the repo root +chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR" +echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")" diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts new file mode 100644 index 0000000..22f1794 --- /dev/null +++ b/scripts/live-probe-provider-retry.ts @@ -0,0 +1,188 @@ +/** + * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient + * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up). + * NOT part of `bun run test`. + * + * A real `provider-retry` only fires on an upstream 429/5xx, which we can't + * force from here. So this probe verifies the two things unit tests CAN'T: + * + * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket + + * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper) + * seals cleanly and `providerRetry` stays NULL throughout — no spurious + * banner, and the wrapper's re-spread didn't break streaming. + * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a + * synthetic `provider-retry` `chat.delta` JSON string through the REAL + * `parseServerMessage` wire parser (the function that runs on every inbound + * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) → + * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk → + * a subsequent `text-delta` CLEARS it. This proves the new variant survives + * the JSON-parse boundary the unit tests skip (they pass constructed events). + * + * bun scripts/live-probe-provider-retry.ts + * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts + */ +import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract"; +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { createSurfaceSocket } from "../src/adapters/ws/index.ts"; +import { parseServerMessage } from "../src/adapters/ws/logic.ts"; +import { + foldEvent, + initialState, + selectChunks, + selectProviderRetry, +} from "../src/core/chunks/index.ts"; + +const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205"; +const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash"; +const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok"; + +type ChatMsg = ChatDeltaMessage | ChatErrorMessage; + +const checks: { name: string; ok: boolean; detail?: string }[] = []; +const record = (name: string, ok: boolean, detail?: string) => { + checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) }); + console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); +}; +const fail = (msg: string): never => { + console.error(`\n[probe] FATAL: ${msg}`); + process.exit(1); +}; + +/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */ +function deltaFrame(event: ChatDeltaMessage["event"]): string { + return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage); +} + +async function main() { + console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`); + + // ─── 1. REGRESSION: a real text turn through the updated foldEvent ────────── + // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts). + const handlers = new Map<string, (msg: ChatMsg) => void>(); + const socket = createSurfaceSocket({ + url: WS_URL, + onMessage: (_m: SurfaceServerMessage) => {}, + onChat: (msg: ChatMsg) => { + const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; + const h = id !== undefined ? handlers.get(id) : undefined; + h?.(msg); + }, + }); + await new Promise((r) => setTimeout(r, 500)); + + const conversationId = crypto.randomUUID(); + let state = initialState(); + let deltas = 0; + let sealed = false; + let error: string | null = null; + const done = Promise.withResolvers<void>(); + handlers.set(conversationId, (msg) => { + if (msg.type === "chat.error") { + error = msg.message; + done.resolve(); + return; + } + deltas++; + state = foldEvent(state, msg.event); + if (msg.event.type === "turn-sealed") { + sealed = true; + done.resolve(); + } + }); + + socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL }); + const timeout = setTimeout(() => done.resolve(), 90_000); + await done.promise; + clearTimeout(timeout); + handlers.delete(conversationId); + + record( + "regression: a real text turn sealed cleanly", + sealed && error === null, + `${deltas} deltas${error ? ` err=${error}` : ""}`, + ); + record( + "regression: providerRetry stayed NULL through a normal turn (no spurious banner)", + selectProviderRetry(state) === null, + ); + + // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─ + console.log("\n[probe] parser+reducer seam (synthetic provider-retry)"); + let s = initialState(); + s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" }); + + const retryJson = deltaFrame({ + type: "provider-retry", + conversationId, + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}', + code: "429", + }); + const parsed1 = parseServerMessage(retryJson); + record( + "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)", + parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry", + parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null", + ); + + if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event); + const retry1 = selectProviderRetry(s); + record( + "foldEvent SETS providerRetry from the PARSED event", + retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429", + retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null", + ); + record( + "provider-retry adds NO chunk (never persisted — never pollutes the prompt)", + selectChunks(s).length === 0, + `${selectChunks(s).length} chunk(s)`, + ); + + const retry2Json = deltaFrame({ + type: "provider-retry", + conversationId, + turnId: "t1", + attempt: 1, + delayMs: 10000, + message: "HTTP 429: still overloaded", + code: "429", + }); + const parsed2 = parseServerMessage(retry2Json); + if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event); + const retry2 = selectProviderRetry(s); + record( + "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)", + retry2 !== null && + retry2.attempt === 1 && + retry2.delayMs === 10000 && + retry2.message === "HTTP 429: still overloaded", + retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null", + ); + + const textJson = deltaFrame({ + type: "text-delta", + conversationId, + turnId: "t1", + delta: "here is the reply", + }); + const parsedText = parseServerMessage(textJson); + if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event); + record( + "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)", + selectProviderRetry(s) === null, + ); + record( + "…and the text-delta content DID land as a chunk (the reply streams normally after retries)", + selectChunks(s).some((c) => c.chunk.type === "text"), + ); + + socket.close(); + const passed = checks.filter((c) => c.ok).length; + const total = checks.length; + console.log(`\n[probe] ${passed}/${total} checks passed`); + process.exit(passed === total ? 0 : 1); +} + +main().catch((e) => fail(String(e))); |
