diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 19:53:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 19:53:51 +0900 |
| commit | 61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e (patch) | |
| tree | 47f8a60a0c4c51c1fa8beee2cb90fd54abf91c68 | |
| parent | 44e27177892a48a51c440676ff3f6613deef5164 (diff) | |
| download | dispatch-61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e.tar.gz dispatch-61b6e24c7abb4eebf94da0a0498a68a1bb8ba92e.zip | |
feat(transport-http): GET /conversations/:id?sinceSeq= read-side history endpoint
Incremental rehydration endpoint for long-lived clients. Returns
ConversationHistoryResponse { chunks: StoredChunk[], latestSeq } — the RAW,
append-order, seq-filtered slice from conversation-store.loadSince, NOT
reconciled (reconcile conflicts with the per-chunk seq cursor, so it stays on
the turn path; the read path is a pure sync primitive).
- transport-contract: add ConversationHistoryResponse + StoredChunk re-export.
- transport-http: GET /conversations/:id route reaching the log directly via
conversationStoreHandle (dependsOn conversation-store); pure parseSinceSeq
(absent->0, invalid->400).
- build wiring: conversation-store dep + project ref.
FE Slice 2 backend prereq (read-side). typecheck clean, 481 vitest, biome clean.
| -rw-r--r-- | bun.lock | 1 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 30 | ||||
| -rw-r--r-- | packages/transport-http/package.json | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 129 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 27 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 13 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 18 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 42 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 15 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/tsconfig.json | 1 | ||||
| -rw-r--r-- | tasks.md | 22 |
12 files changed, 284 insertions, 17 deletions
@@ -150,6 +150,7 @@ "name": "@dispatch/transport-http", "version": "0.0.0", "dependencies": { + "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 7d3996a..9d8f6f4 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -12,7 +12,9 @@ * union, re-exported here so a client has one import for the whole wire. */ -export type { AgentEvent } from "@dispatch/wire"; +import type { StoredChunk } from "@dispatch/wire"; + +export type { AgentEvent, StoredChunk } from "@dispatch/wire"; /** * Request body for `POST /chat` (sent as JSON). @@ -55,3 +57,29 @@ export interface ChatRequest { export interface ModelsResponse { readonly models: readonly string[]; } + +/** + * Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental + * read-side history endpoint a long-lived client uses to (re)hydrate a + * conversation cheaply. + * + * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log + * with `seq > sinceSeq` (or the whole log when `sinceSeq` is omitted/0). It is + * NOT reconciled: a dangling tool-call is returned as-is (rendered as an + * interrupted call). Reconciliation is a turn-path concern — the server repairs + * history only when it feeds a provider, never on this read path — which is what + * preserves the per-chunk `seq` cursor invariant (a synthesized repair chunk + * would have no seq). + * + * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the + * slice is empty (the client is already caught up) — the requested `sinceSeq` + * (0 for a full read of an empty conversation). So after applying the response a + * client's new cursor is always `latestSeq`, and an empty `chunks` means + * "nothing new past your cursor". (A true server-side high-water mark + * independent of the filter is deferred until a consumer needs it — it would + * require widening the store contract.) + */ +export interface ConversationHistoryResponse { + readonly chunks: readonly StoredChunk[]; + readonly latestSeq: number; +} diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index eacf39a..5b6846f 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { + "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 38089e4..f4707bc 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,7 +1,23 @@ -import type { AgentEvent } from "@dispatch/kernel"; +import type { AgentEvent, StoredChunk } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; -import type { CredentialStore, SessionOrchestrator } from "./seam.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; + +function createFakeConversationStore( + store: Map<string, StoredChunk[]> = new Map(), +): ConversationStore { + return { + async append() {}, + async load() { + return []; + }, + async loadSince(conversationId, sinceSeq) { + const chunks = store.get(conversationId) ?? []; + const minSeq = sinceSeq ?? 0; + return chunks.filter((c) => c.seq > minSeq); + }, + }; +} function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { return { @@ -62,6 +78,7 @@ function createThrowingCredentialStore(error: Error): CredentialStore { describe("GET /health", () => { it("returns ok", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -75,6 +92,7 @@ describe("GET /health", () => { describe("GET /models", () => { it("returns model catalog", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), }); @@ -86,6 +104,7 @@ describe("GET /models", () => { it("returns empty array when no models", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -97,6 +116,7 @@ describe("GET /models", () => { it("returns 502 when listCatalog throws", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createThrowingCredentialStore(new Error("db down")), }); @@ -110,6 +130,7 @@ describe("GET /models", () => { describe("POST /chat", () => { it("returns 400 for invalid JSON", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -123,6 +144,7 @@ describe("POST /chat", () => { it("returns 400 for missing message", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -138,6 +160,7 @@ describe("POST /chat", () => { it("returns 400 for empty message", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -157,6 +180,7 @@ describe("POST /chat", () => { { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]; const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator(events), credentialStore: createFakeCredentialStore([]), }); @@ -185,6 +209,7 @@ describe("POST /chat", () => { it("generates conversationId when not provided", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]), @@ -204,6 +229,7 @@ describe("POST /chat", () => { it("emits error event when orchestrator throws", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), credentialStore: createFakeCredentialStore([]), }); @@ -230,6 +256,7 @@ describe("POST /chat", () => { it("handles empty event list", async () => { const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -248,6 +275,7 @@ describe("POST /chat", () => { it("forwards modelName and cwd to orchestrator", async () => { const cap = createCapturingOrchestrator(); const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -274,6 +302,7 @@ describe("POST /chat", () => { it("omits modelName and cwd when not provided", async () => { const cap = createCapturingOrchestrator(); const app = createApp({ + conversationStore: createFakeConversationStore(), orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -290,3 +319,99 @@ describe("POST /chat", () => { expect(cap.received?.cwd).toBeUndefined(); }); }); + +describe("GET /conversations/:id", () => { + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, + ]; + + it("returns the full seq-ordered StoredChunk history", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(4); + expect(body.chunks[0]?.seq).toBe(1); + expect(body.chunks[3]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns only chunks with seq > N and latestSeq = last seq", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(2); + expect(body.chunks[0]?.seq).toBe(3); + expect(body.chunks[1]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=4"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(0); + }); + + it("returns 400 for invalid sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=abc"); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("sinceSeq"); + }); + + it("returns 400 for negative sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=-1"); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index d5492ce..5f63683 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,10 +1,17 @@ import type { AgentEvent } from "@dispatch/kernel"; -import type { ModelsResponse } from "@dispatch/transport-contract"; +import type { ConversationHistoryResponse, ModelsResponse } from "@dispatch/transport-contract"; import { Hono } from "hono"; -import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -import type { CredentialStore, SessionOrchestrator } from "./seam.js"; +import { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; export interface CreateServerOptions { + readonly conversationStore: ConversationStore; readonly orchestrator: SessionOrchestrator; readonly credentialStore: CredentialStore; readonly generateId?: () => string; @@ -16,6 +23,20 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/health", (c) => c.json({ ok: true })); + app.get("/conversations/:id", async (c) => { + const conversationId = c.req.param("id"); + const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); + if (isSinceSeqError(sinceSeqResult)) { + return c.json({ error: sinceSeqResult.error }, 400); + } + + const chunks = await opts.conversationStore.loadSince(conversationId, sinceSeqResult); + const latestSeq = + chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; + const body: ConversationHistoryResponse = { chunks, latestSeq }; + return c.json(body, 200); + }); + app.get("/models", async (c) => { try { const models = await opts.credentialStore.listCatalog(); diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index e9613c5..ba45f9d 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,7 +1,11 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import type { Hono } from "hono"; import { createApp } from "./app.js"; -import { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; +import { + conversationStoreHandle, + credentialStoreHandle, + sessionOrchestratorHandle, +} from "./seam.js"; export const manifest: Manifest = { id: "transport-http", @@ -9,9 +13,9 @@ export const manifest: Manifest = { version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", - dependsOn: ["credential-store", "session-orchestrator"], + dependsOn: ["conversation-store", "credential-store", "session-orchestrator"], capabilities: { network: true }, - contributes: { routes: ["/chat", "/health", "/models"] }, + contributes: { routes: ["/chat", "/conversations/:id", "/health", "/models"] }, activation: "eager", }; @@ -20,9 +24,10 @@ export interface CreateServerOptions { } export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { + const conversationStore = host.getService(conversationStoreHandle); const orchestrator = host.getService(sessionOrchestratorHandle); const credentialStore = host.getService(credentialStoreHandle); - return createApp({ orchestrator, credentialStore }); + return createApp({ conversationStore, orchestrator, credentialStore }); } export const extension: Extension = { diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index d91f9ad..d92319c 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -1,7 +1,17 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; export { createServer, extension, manifest } from "./extension.js"; -export type { ChatCommand, ParseError, ParseResult } from "./logic.js"; -export { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -export type { CredentialStore, SessionOrchestrator } from "./seam.js"; -export { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; +export type { ChatCommand, ParseError, ParseResult, SinceSeqResult } from "./logic.js"; +export { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; +export type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; +export { + conversationStoreHandle, + credentialStoreHandle, + sessionOrchestratorHandle, +} from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index 141549f..1e33f40 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -1,6 +1,12 @@ import type { AgentEvent } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; -import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; +import { + isParseError, + isSinceSeqError, + parseChatBody, + parseSinceSeq, + serializeEventLine, +} from "./logic.js"; describe("parseChatBody", () => { const fakeId = () => "test-uuid"; @@ -132,6 +138,40 @@ describe("parseChatBody", () => { }); }); +describe("parseSinceSeq", () => { + it("returns 0 when undefined", () => { + expect(parseSinceSeq(undefined)).toBe(0); + }); + + it("returns 0 when empty string", () => { + expect(parseSinceSeq("")).toBe(0); + }); + + it("parses valid non-negative integer", () => { + expect(parseSinceSeq("0")).toBe(0); + expect(parseSinceSeq("5")).toBe(5); + expect(parseSinceSeq("42")).toBe(42); + }); + + it("returns ParseError for non-integer string", () => { + const result = parseSinceSeq("abc"); + expect(isSinceSeqError(result)).toBe(true); + if (isSinceSeqError(result)) { + expect(result.error).toContain("sinceSeq"); + } + }); + + it("returns ParseError for float", () => { + const result = parseSinceSeq("3.14"); + expect(isSinceSeqError(result)).toBe(true); + }); + + it("returns ParseError for negative integer", () => { + const result = parseSinceSeq("-1"); + expect(isSinceSeqError(result)).toBe(true); + }); +}); + describe("serializeEventLine", () => { it("serializes an event as JSON followed by newline", () => { const event: AgentEvent = { diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 11133a5..f1d5b8c 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -13,6 +13,8 @@ export interface ParseError { export type ParseResult = ChatCommand | ParseError; +export type SinceSeqResult = number | ParseError; + export function parseChatBody(body: unknown, generateId: () => string): ParseResult { if (body === null || typeof body !== "object") { return { error: "Request body must be a JSON object" }; @@ -56,3 +58,16 @@ export function isParseError(result: ParseResult): result is ParseError { export function serializeEventLine(event: AgentEvent): string { return `${JSON.stringify(event)}\n`; } + +export function parseSinceSeq(raw: string | undefined): SinceSeqResult { + if (raw === undefined || raw === "") return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: "sinceSeq must be a non-negative integer" }; + } + return n; +} + +export function isSinceSeqError(result: SinceSeqResult): result is ParseError { + return typeof result === "object"; +} diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index 297fb22..c7bfb74 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -1,3 +1,5 @@ +export type { ConversationStore } from "@dispatch/conversation-store"; +export { conversationStoreHandle } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; export type { SessionOrchestrator } from "@dispatch/session-orchestrator"; diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json index a6d1ca8..bcbf86a 100644 --- a/packages/transport-http/tsconfig.json +++ b/packages/transport-http/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], "references": [ + { "path": "../conversation-store" }, { "path": "../credential-store" }, { "path": "../kernel" }, { "path": "../session-orchestrator" }, @@ -393,8 +393,26 @@ streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../ zero internal `@dispatch/*` mocks, both agents in-lane. Read-side HTTP endpoint (`GET /conversations/:id?sinceSeq=`) + WS turn-deltas remain their own following steps. -- [ ] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → reconciled `Chunk[]`/ - `ChatMessage[]` (transport-http + conversation-store). +- [x] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → RAW seq'd stream. DONE + verified. + Design (user-confirmed): returns `ConversationHistoryResponse { chunks: StoredChunk[], latestSeq }` + — the RAW, append-order, seq-filtered slice from `conversation-store.loadSince`, **NOT + reconciled**. `reconcile` (message-level repair) conflicts with per-chunk `seq` (a synthesized + repair chunk has no seq), so reconciliation stays on the TURN path (session-orchestrator before + `runTurn`); the read path is a pure sync primitive (FE renders a dangling tool-call as + interrupted; FE commits only sealed turns §6.6). `latestSeq` = last returned chunk's seq, else + `sinceSeq` (true server high-water mark deferred until a consumer needs it — avoids widening the + store contract). + - **Contract + wiring (orchestrator):** added `ConversationHistoryResponse` + `StoredChunk` + re-export to `@dispatch/transport-contract`; added `@dispatch/conversation-store` dep + + project ref to transport-http; `bun install`. + - **transport-http (owner, mimo-v2.5-pro):** `GET /conversations/:id?sinceSeq=` route; reaches + the log DIRECTLY via `conversationStoreHandle` (`dependsOn:["conversation-store"]`), not through + the orchestrator; pure `parseSinceSeq` (absent→0, invalid/negative/float→400) in `logic.ts`; + +6 logic + 6 app integration tests (via real Hono `app.fetch`). prompts/read-side-endpoint.md, + reports/transport-http.md. + - **Verified (orchestrator):** typecheck clean, **481 vitest** (469→+12), biome clean, no internal + `@dispatch/*` mocks, in-lane. Live boot-probe deferred to the WS step (this GET route has no + effectful-shell surprise surface; host wiring mirrors `/chat`). - [ ] **WS turn-deltas** — `transport-ws` multiplexes `sendMessage`/`onDelta(AgentEvent)` alongside surface ops (one connection carries both; frontend-design §5). Then FE (`../dispatch-web`): `core/transcript` reducer + `conversation-cache` + `chat` feature. |
