diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 80 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 21 | ||||
| -rw-r--r-- | backend-handoff.md | 31 | ||||
| -rw-r--r-- | src/adapters/ws/index.test.ts | 24 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 5 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 16 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 10 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 30 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 11 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 |
10 files changed, 217 insertions, 13 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 18d1a3d..d5f22c7 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,10 +5,21 @@ > hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — > this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (message queue + steering). -> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see +> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation.open broadcast). +> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see > `ui-contract.reference.md`). > +> **2026-06-21 delta (conversation.open handoff — package bumped `0.12.0` → `0.13.0`, ADDITIVE):** +> adds the `conversation.open` WS broadcast — when the CLI's `--open` flag fires +> (`POST /conversations/:id/open`), the backend broadcasts a `ConversationOpenMessage` +> (`{ type: "conversation.open"; conversationId }`) to ALL connected WS clients. Additive to +> `WsServerMessage`. The FE handles it by opening/focusing a tab for the `conversationId`. Also +> adds conversation metadata endpoints (not yet consumed by the FE): `GET /conversations` (list, +> `ConversationListResponse`/`ConversationMeta`), `GET /conversations/:id/last` (blocking last +> message, `LastMessageResponse`), `GET`/`PUT /conversations/:id/title` (`TitleResponse`/ +> `SetTitleRequest`), and `POST /conversations/:id/open` (`OpenConversationResponse`). Re-exports +> `ConversationMeta` from `[email protected]`. +> > **2026-06-21 delta (message-queue + steering handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** > adds the enqueue surface for the per-conversation message queue (the wire types `QueuedMessage` / > `QueuePayload` + the new `steering` `AgentEvent` live in `[email protected]`, re-exported here). Two @@ -200,6 +211,7 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { AgentEvent, + ConversationMeta, QueuedMessage, ReasoningEffort, StoredChunk, @@ -208,6 +220,7 @@ import type { export type { AgentEvent, + ConversationMeta, QueuedMessage, ReasoningEffort, StepMetrics, @@ -649,5 +662,66 @@ export type WsClientMessage = * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ -export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage; +export type WsServerMessage = + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ConversationOpenMessage; + +// ─── Conversation list + metadata ──────────────────────────────────────────── + +/** + * Broadcast to all connected WS clients when a conversation is "opened" (e.g. + * via the CLI `--open` flag). The frontend decides whether to open/focus a tab + * — the backend just signals. Additive to `WsServerMessage`. + */ +export interface ConversationOpenMessage { + readonly type: "conversation.open"; + readonly conversationId: string; +} + +/** + * Response for `GET /conversations` — the list of all known conversations, + * sorted by `lastActivityAt` descending (most recent first). Each entry carries + * enough metadata for a conversation picker UI (id, title, timestamps). + * Optional `?q=` query param filters by id prefix (short-id resolution). + */ +export interface ConversationListResponse { + readonly conversations: readonly ConversationMeta[]; +} + +/** + * Response for `GET /conversations/:id/last` — blocks server-side until the + * in-flight turn settles (if one is active), then returns the last assistant + * text message. `content` is empty if the conversation has no assistant message. + * `turnId` is the turn that produced the message (absent if no turn ran). + */ +export interface LastMessageResponse { + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; +} + +/** + * Response for `POST /conversations/:id/open` — confirms the conversation.open + * signal was broadcast to connected WS clients. + */ +export interface OpenConversationResponse { + readonly conversationId: string; +} + +/** + * Request body for `PUT /conversations/:id/title` — set a human-readable title. + */ +export interface SetTitleRequest { + readonly title: string; +} + +/** + * Response for `GET/PUT /conversations/:id/title` — the current title. + */ +export interface TitleResponse { + readonly conversationId: string; + readonly title: string; +} ``` diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index c2c4d43..e96c353 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,9 +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]` (message queue + steering). Regenerate +> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation metadata). Regenerate > whenever `@dispatch/wire` changes. > +> **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`, @@ -577,4 +581,19 @@ export interface TurnSteeringEvent { readonly turnId: string; readonly text: string; } + +// ─── Conversation metadata ─────────────────────────────────────────────────── + +/** + * 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. + */ +export interface ConversationMeta { + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; +} ``` diff --git a/backend-handoff.md b/backend-handoff.md index 5b54f2d..64147ff 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,18 +5,32 @@ > **From:** dispatch-web orchestrator · **To:** arch-rewrite 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-21 (message-queue + steering handoff consumed). **FE is current on +_Last updated: 2026-06-21 (conversation.open handoff consumed). **FE is current on consumed: surfaces + WS, conversation transcript/metrics, tabs + model selector, cache-warming (incl. authoritative timer + retention + cache-rate fix + the CR-4 lifecycle below), **per-conversation cwd + LSP status**, **context size**, **turn continuity + multi-client live view**, the **chat limit + CR-5 history windowing**, the **reasoning effort -(thinking-depth knob)**, and the **message queue + steering** (below). +(thinking-depth knob)**, the **message queue + steering**, the **todo task list**, and the +**conversation.open broadcast** (below). **Open asks: NONE.** CR-1/CR-2/CR-4/CR-5 all RESOLVED ✅ (see §2); §3 lists likely next asks. **CR-3 (watcher couldn't see the USER prompt until seal) → RESOLVED ✅** — backend shipped the `user-message` turn event; FE re-pinned + consumption live. The cwd/LSP draft-path verification (`backend-handoff-cwd-lsp.md`) came back **all ✅ confirmed**._ +**Conversation.open handoff (`frontend-conversation-open-handoff.md`) → CONSUMED ✅.** +Re-pinned `[email protected]→0.9.0` + `[email protected]→0.13.0` (`ui-contract` unchanged); +re-mirrored both `.dispatch/*.reference.md`. FE work: the WS adapter (`adapters/ws/logic.ts` + +`index.ts`) parses + routes the new `"conversation.open"` top-level `WsServerMessage` to an +`onConversationOpen` handler; the app store's `openConversation(conversationId)` opens (or +focuses) a tab for the broadcast conversation — if not already open, creates a chat store, +loads history, subscribes to live turns, creates the tab; then selects it + refreshes +cwd/effort/surfaces. The conformance guard + WS adapter tests cover the new type. The backend +also shipped conversation metadata endpoints (`GET /conversations`, `GET /conversations/:id/last`, +`GET`/`PUT /conversations/:id/title`, `POST /conversations/:id/open`) — NOT yet consumed by the FE +(mirrored for reference; wire when a conversation picker/list UI is built). 682 tests green. NO +new backend ask._ + **Message-queue + steering handoff (`frontend-message-queue-handoff.md`) → CONSUMED ✅.** Re-pinned `[email protected]→0.8.0` + `[email protected]→0.12.0` (`ui-contract` unchanged — the queue uses the existing `custom` surface field kind); re-mirrored both @@ -101,13 +115,13 @@ backend ask — but the max-limit denominator is now a live FE need; see §3. ## 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`** | -| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + **`QueueRequest`/`QueueResponse`/`ChatQueueMessage`** + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | +| `@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`** | +| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + **`QueueRequest`/`QueueResponse`/`ChatQueueMessage`** + **`ConversationOpenMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse`** + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): `POST /chat` (NDJSON) · `GET /models` · @@ -118,11 +132,12 @@ Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): 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 `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). 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]`). ## 2. Open asks FOR THE BACKEND diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 961f919..e13f123 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -269,6 +269,30 @@ describe("createSurfaceSocket", () => { expect(onMessage).not.toHaveBeenCalled(); }); + it("routes conversation.open to onConversationOpen", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + const onConversationOpen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + onConversationOpen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })); + expect(onConversationOpen).toHaveBeenCalledOnce(); + expect(onConversationOpen).toHaveBeenCalledWith({ + type: "conversation.open", + conversationId: "c1", + }); + expect(onMessage).not.toHaveBeenCalled(); + expect(onChat).not.toHaveBeenCalled(); + }); + it("still routes surface catalog/surface to onMessage", () => { const ws = fakeSocket(); const onMessage = vi.fn(); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 54a501c..18ebdf7 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,6 +1,7 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ConversationOpenMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -18,6 +19,8 @@ export interface SurfaceSocketOptions { url: string; onMessage: (msg: SurfaceServerMessage) => void; onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; + /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ + onConversationOpen?: (msg: ConversationOpenMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -60,6 +63,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa if (msg !== null) { if (msg.type === "chat.delta" || msg.type === "chat.error") { opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); + } else if (msg.type === "conversation.open") { + opts.onConversationOpen?.(msg as ConversationOpenMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 2784295..ca129c0 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -217,6 +217,22 @@ describe("parseServerMessage", () => { ), ).toBeNull(); }); + + it("parses a conversation.open message", () => { + const data = JSON.stringify({ type: "conversation.open", conversationId: "c1" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "conversation.open", conversationId: "c1" }); + }); + + it("returns null for conversation.open with missing conversationId", () => { + expect(parseServerMessage(JSON.stringify({ type: "conversation.open" }))).toBeNull(); + }); + + it("returns null for conversation.open with non-string conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })), + ).toBeNull(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 17e3951..a9b70ff 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,6 +1,7 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ConversationOpenMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -18,6 +19,7 @@ const VALID_SERVER_TYPES = new Set([ "error", "chat.delta", "chat.error", + "conversation.open", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -107,6 +109,14 @@ export function parseServerMessage(data: string): WsServerMessage | null { : { type: "chat.error", message: parsed.message }; return msg; } + case "conversation.open": { + if (typeof parsed.conversationId !== "string") return null; + const msg: ConversationOpenMessage = { + type: "conversation.open", + conversationId: parsed.conversationId, + }; + return msg; + } default: return null; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index dc06ea1..5159353 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -3,6 +3,7 @@ import type { ChatErrorMessage, ConversationHistoryResponse, ConversationMetricsResponse, + ConversationOpenMessage, CwdResponse, LspStatusResponse, ModelsResponse, @@ -432,10 +433,39 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let socket: ReturnType<typeof createSurfaceSocket> | null = null; + /** + * Open (or focus) a conversation tab — used by the `conversation.open` WS + * broadcast (CLI `--open` flag). If the conversation is already open, just + * focus it; otherwise create a chat store, load its history, subscribe to its + * live turns, and create+select the tab. + */ + function openConversation(conversationId: string): void { + const alreadyOpen = chatStores.has(conversationId); + if (!alreadyOpen) { + const store = createChatFor(conversationId, activeModel); + chatStores.set(conversationId, store); + void store.load(); + subscribeChat(conversationId); + tabsStore.createTab({ + conversationId, + model: activeModel, + title: "Conversation", + }); + } + tabsStore.selectTab(conversationId); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshReasoningEffort(); + } + const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, onChat: handleChatMessage, + onConversationOpen(msg: ConversationOpenMessage): void { + openConversation(msg.conversationId); + }, onReopen() { // The server forgot our subscriptions on reconnect; re-send each with the // conversation it was subscribed under (protocolSubscribe would no-op since diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index 2fdd3cb..f5d6608 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -139,9 +139,18 @@ describe("classifies every WsServerMessage type", () => { event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, }, { type: "chat.error" as const, message: "e" }, + { type: "conversation.open" as const, conversationId: "c1" }, ]; const labels = msgs.map(assertWsServerMessageExhaustive); - expect(labels).toEqual(["catalog", "surface", "update", "error", "chat.delta", "chat.error"]); + expect(labels).toEqual([ + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + ]); }); }); diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 6e87e5c..05a15aa 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -81,6 +81,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "chat.delta"; case "chat.error": return "chat.error"; + case "conversation.open": + return "conversation.open"; default: return msg satisfies never; } |
