diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 23:43:43 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 23:43:43 +0900 |
| commit | fac44794432928d0341728642fd70eef87837da4 (patch) | |
| tree | 53445547f98bf5e798966efb339ce67cd8ebd20b /.dispatch | |
| parent | f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa (diff) | |
| download | dispatch-web-fac44794432928d0341728642fd70eef87837da4.tar.gz dispatch-web-fac44794432928d0341728642fd70eef87837da4.zip | |
Slice 2 unblock: pin wire + transport-contract; mirror contracts
- pin @dispatch/wire + @dispatch/transport-contract (+ ui-contract) as file:
deps @0.1.0; overrides{} workaround for their workspace:* deps (bun can't
resolve workspace: from outside the monorepo)
- add fake-indexeddb (dev) for the upcoming IndexedDB adapter tests
- mirror wire + transport-contract into .dispatch/*.reference.md for headless
agents; point package-agent.md at all three references
- backend-handoff.md: convert to a living FE<->backend seam doc
Verified green: svelte-check 0/0, vitest 91, biome clean, build ok; contract
import smoke-test passes.
Diffstat (limited to '.dispatch')
| -rw-r--r-- | .dispatch/package-agent.md | 17 | ||||
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 125 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 247 |
3 files changed, 384 insertions, 5 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md index a5666e4..c98b326 100644 --- a/.dispatch/package-agent.md +++ b/.dispatch/package-agent.md @@ -23,11 +23,18 @@ it, test it, and write a report — nothing else. If no single unit is named, st ## What you may read (visibility) - **Your own unit:** every file, freely. -- **The contract you consume:** reproduced IN-REPO at - `.dispatch/ui-contract.reference.md` — read THAT. Your code imports - `@dispatch/ui-contract` normally, but **do NOT read `node_modules/@dispatch/*`** — it - symlinks to the backend repo (OUTSIDE this repo) and a headless permission prompt will - HANG the run (see "Headless read boundary"). +- **The contracts you consume:** reproduced IN-REPO under `.dispatch/*.reference.md` — read THOSE: + - `.dispatch/ui-contract.reference.md` — `@dispatch/ui-contract` (surfaces + surface WS protocol). + - `.dispatch/wire.reference.md` — `@dispatch/wire` (`Chunk`/`StoredChunk`+`seq`/`ChatMessage`/ + `AgentEvent`/`TurnSealedEvent`/`Usage` — the chat wire types). + - `.dispatch/transport-contract.reference.md` — `@dispatch/transport-contract` (HTTP endpoints + + `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse` + WS chat ops + the unified + `WsClientMessage`/`WsServerMessage` unions). + + Your code imports `@dispatch/ui-contract` / `@dispatch/wire` / `@dispatch/transport-contract` + normally, but **do NOT read `node_modules/@dispatch/*`** — they symlink to the backend repo + (OUTSIDE this repo) and a headless permission prompt will HANG the run (see "Headless read + boundary"). - **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read their internals (needing them ⇒ the contract is incomplete → report a CR). diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md new file mode 100644 index 0000000..3a7a59c --- /dev/null +++ b/.dispatch/transport-contract.reference.md @@ -0,0 +1,125 @@ +# `@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 HTTP + WebSocket wire shapes 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]`. Regenerate whenever it changes. +> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract` +> (see `ui-contract.reference.md`). + +## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205) + +- `POST /chat` — body `ChatRequest` (JSON); response NDJSON stream, one `AgentEvent` per line; + resolved id also in `X-Conversation-Id` header. +- `GET /models` — `ModelsResponse`. +- `GET /conversations/:id?sinceSeq=<n>` — `ConversationHistoryResponse`: RAW, append-order, + seq-ordered slice with `seq > n` (NOT reconciled — dangling tool-calls returned as-is). + `latestSeq` = last chunk's `seq`, or the requested `sinceSeq` when caught up (empty `chunks`). +- WebSocket on :24205 — ONE path-agnostic socket multiplexes surface ops + (`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive + `WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`** + (seq lives only on `StoredChunk`, obtained via the `sinceSeq` sync after `turn-sealed`). +- DEFERRED (not built; do not depend on): `GET /conversations` (list), `POST /conversations/:id/cancel`. + +```ts +/** + * Transport contract — the typed description of Dispatch's client–server API + * (HTTP + WebSocket). Types-only (zero runtime). Each side owns its own + * (de)serialization — the contract is the SHAPES, not the codec. + * + * The WebSocket carries BOTH chat ops (here) and surface ops (in + * `@dispatch/ui-contract`) over one connection; the unified `WsClientMessage` / + * `WsServerMessage` unions below compose them. Chat ops are new, non-colliding + * `type` variants (`chat.*`) — the shipped surface protocol is unchanged. + */ + +import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; +import type { AgentEvent, StoredChunk } from "@dispatch/wire"; + +export type { AgentEvent, StoredChunk } from "@dispatch/wire"; + +/** + * Request body for `POST /chat` (sent as JSON). + * + * The response is an NDJSON stream: one JSON-encoded `AgentEvent` per line. + * The resolved conversation id is also returned in the `X-Conversation-Id` + * response header (useful when `conversationId` was omitted). + */ +export interface ChatRequest { + /** The conversation to continue. Omit to start fresh — server mints an id (X-Conversation-Id). */ + readonly conversationId?: string; + /** The user's message text for this turn. */ + readonly message: string; + /** Model name in `<credentialName>/<model>` form (one of `GET /models`). Omit = server default. */ + readonly model?: string; + /** Working directory for this turn's tool execution. Defaults server-side. Not part of the prompt. */ + readonly cwd?: string; +} + +/** + * Response body for `GET /models` — the model catalog. Each entry is a model + * name in `<credentialName>/<model>` form (exactly `ChatRequest.model`). + */ +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 cheaply. + * + * `chunks` is the RAW, append-order, seq-ordered slice with `seq > sinceSeq` + * (or the whole log when `sinceSeq` is omitted/0). NOT reconciled: a dangling + * tool-call is returned as-is. `latestSeq` is the `seq` of the LAST chunk, or — + * when the slice is empty (caught up) — the requested `sinceSeq` (0 for a full + * read of an empty conversation). After applying, the client's new cursor is + * always `latestSeq`; empty `chunks` means "nothing new past your cursor". + */ +export interface ConversationHistoryResponse { + readonly chunks: readonly StoredChunk[]; + readonly latestSeq: number; +} + +// ─── WebSocket chat ops ─────────────────────────────────────────────────────── +// The persistent WS connection multiplexes chat ops (below) with surface ops +// (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they +// never collide with surface ones. + +/** + * Client → server: start or continue a turn over the WS connection. Same fields + * as the HTTP `ChatRequest`; omit `conversationId` to start fresh — the resolved + * id arrives on the streamed `AgentEvent`s (each carries `conversationId`). + */ +export interface ChatSendMessage extends ChatRequest { + readonly type: "chat.send"; +} + +/** + * Server → client: one `AgentEvent` from an in-flight turn (text-delta, + * tool-call, usage, done, turn-sealed, …). Fold these into the transcript + * exactly as the HTTP NDJSON stream — same events, different carrier. + */ +export interface ChatDeltaMessage { + readonly type: "chat.delta"; + readonly event: AgentEvent; +} + +/** + * Server → client: a chat-scoped TRANSPORT error — e.g. a malformed `chat.send` + * or a failure before a turn could start. (Errors DURING a turn arrive as a + * `TurnErrorEvent` inside a `chat.delta`.) + */ +export interface ChatErrorMessage { + readonly type: "chat.error"; + readonly conversationId?: string; + readonly message: string; +} + +/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */ +export type WsClientMessage = SurfaceClientMessage | ChatSendMessage; + +/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */ +export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage; +``` diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md new file mode 100644 index 0000000..ccf07bd --- /dev/null +++ b/.dispatch/wire.reference.md @@ -0,0 +1,247 @@ +# `@dispatch/wire` — in-repo reference (read THIS, not node_modules) + +> MIRRORS the backend's `@dispatch/wire` package source so headless FE agents can read the wire +> 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]`. Regenerate whenever `@dispatch/wire` changes. + +```ts +/** + * @dispatch/wire — pure wire types shared by the kernel, the transport + * contract, and out-of-repo clients (the web frontend). + * + * Types ONLY: zero runtime, zero `@dispatch/*` dependencies, so a client can + * depend on the wire without pulling the kernel runtime. + */ + +// ─── Conversation model ───────────────────────────────────────────────────── + +/** Who produced a message. */ +export type Role = "system" | "user" | "assistant" | "tool"; + +/** Opaque identifier for a turn (one user→assistant cycle). */ +export type TurnId = string & { readonly __brand: "TurnId" }; + +/** Opaque identifier for a step (one LLM round-trip within a turn). */ +export type StepId = string & { readonly __brand: "StepId" }; + +/** + * A chunk is one ordered piece of a message — the atomic unit of the + * append-only conversation log. Discriminated by `type`. + */ +export type Chunk = + | TextChunk + | ThinkingChunk + | ToolCallChunk + | ToolResultChunk + | ErrorChunk + | SystemChunk; + +/** A piece of plain text content from the assistant or user. */ +export interface TextChunk { + readonly type: "text"; + readonly text: string; +} + +/** A piece of model reasoning / thinking content (e.g. extended thinking). */ +export interface ThinkingChunk { + readonly type: "thinking"; + readonly text: string; +} + +/** + * A model's request to run a tool. The kernel routes by `name`; the tool + * implementation never sees this directly — it receives parsed `input` via + * `ToolContract.execute`. + */ +export interface ToolCallChunk { + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; +} + +/** + * The result of a tool execution, attributed to the originating tool-call id. + * The kernel guarantees every tool-call chunk gets exactly one result chunk + * (synthesized if interrupted — see reconcile). + */ +export interface ToolResultChunk { + readonly type: "tool-result"; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; +} + +/** An error that occurred during generation or tool dispatch. */ +export interface ErrorChunk { + readonly type: "error"; + readonly message: string; + readonly code?: string; +} + +/** + * A system-injected message (e.g. system prompt, context assembly output). + * Kept distinct from text so the log records provenance. + */ +export interface SystemChunk { + readonly type: "system"; + readonly text: string; +} + +/** + * A chat message: a role plus an ordered sequence of chunks. Messages are the + * unit passed to and from the provider; chunks are the unit persisted and + * rendered. + */ +export interface ChatMessage { + readonly role: Role; + readonly chunks: readonly Chunk[]; +} + +/** + * A persisted chunk plus its sync metadata. The append-only conversation log + * stamps every chunk with a monotonic, gap-free, per-conversation `seq` (the + * sync cursor, assigned in append order) and records the `role` of the message + * it belongs to. This makes a flat seq-ordered stream both incrementally + * syncable ("give me chunks after seq N") and regroupable into messages by the + * client. `chunk` is the pure content unit, unchanged — `Chunk` itself never + * carries storage metadata (it is also passed to/from the provider, which has + * no use for a cursor). + */ +export interface StoredChunk { + readonly seq: number; + readonly role: Role; + readonly chunk: Chunk; +} + +// ─── Usage ────────────────────────────────────────────────────────────────── + +/** + * Token usage counters for a single step. All fields are counts of tokens. + * Cache fields are optional because not all providers expose cache metrics. + */ +export interface Usage { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; +} + +// ─── Outward events ───────────────────────────────────────────────────────── + +/** + * The union of all events the runtime emits outward during a turn. + * Consumers (transport, persistence, notifications) pattern-match on `type`. + */ +export type AgentEvent = + | StatusEvent + | TurnStartEvent + | TurnTextDeltaEvent + | TurnReasoningDeltaEvent + | TurnToolCallEvent + | TurnToolResultEvent + | TurnToolOutputEvent + | TurnUsageEvent + | TurnErrorEvent + | TurnDoneEvent + | TurnSealedEvent; + +/** Status change for a conversation (e.g. idle → running). */ +export interface StatusEvent { + readonly type: "status"; + readonly conversationId: string; + readonly status: string; +} + +/** A turn has begun. */ +export interface TurnStartEvent { + readonly type: "turn-start"; + readonly conversationId: string; + readonly turnId: string; +} + +/** Incremental text content from the model during a turn. */ +export interface TurnTextDeltaEvent { + readonly type: "text-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; +} + +/** Incremental reasoning / thinking content during a turn. */ +export interface TurnReasoningDeltaEvent { + readonly type: "reasoning-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; +} + +/** The model has requested a tool to be run. */ +export interface TurnToolCallEvent { + readonly type: "tool-call"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; +} + +/** A tool has completed execution. */ +export interface TurnToolResultEvent { + readonly type: "tool-result"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; +} + +/** Streaming output from a tool execution (e.g. shell stdout/stderr). */ +export interface TurnToolOutputEvent { + readonly type: "tool-output"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly data: string; + readonly stream: "stdout" | "stderr"; +} + +/** Token usage for the current step or turn. */ +export interface TurnUsageEvent { + readonly type: "usage"; + readonly conversationId: string; + readonly turnId: string; + readonly usage: Usage; +} + +/** An error occurred during the turn. */ +export interface TurnErrorEvent { + readonly type: "error"; + readonly conversationId: string; + readonly turnId: string; + readonly message: string; + readonly code?: string; +} + +/** The turn has completed (model finished generating). */ +export interface TurnDoneEvent { + readonly type: "done"; + readonly conversationId: string; + readonly turnId: string; + readonly reason: string; +} + +/** + * The turn has been sealed — all chunks persisted, history is final. + * This is the hook point for post-turn extensions (compaction, cache-warm). + */ +export interface TurnSealedEvent { + readonly type: "turn-sealed"; + readonly conversationId: string; + readonly turnId: string; +} +``` |
