diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 19:39:05 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 19:39:05 +0900 |
| commit | 44e27177892a48a51c440676ff3f6613deef5164 (patch) | |
| tree | c4e11755df86b1284e3b12b46d25ab4c62b81860 | |
| parent | 22936857685c318b71752d625808100b1a96e63e (diff) | |
| download | dispatch-44e27177892a48a51c440676ff3f6613deef5164.tar.gz dispatch-44e27177892a48a51c440676ff3f6613deef5164.zip | |
feat(wire,conversation-store): per-chunk seq sync cursor (StoredChunk)
Add StoredChunk { seq, role, chunk } to @dispatch/wire (re-exported via the
kernel contract shims). Keeps Chunk pure (provider-facing, no cursor); the
sync cursor lives only on the envelope.
conversation-store: rekey conv:<id>:msg:<seq> -> conv:<id>:chunk:<seq>;
append explodes messages into role-tagged seq'd chunks (1-based, gap-free,
monotonic) with internal boundary metadata so load() round-trips ChatMessage[]
losslessly and still reconciles; new loadSince(id, sinceSeq?) raw sync stream.
session-orchestrator test fake conforms to the widened interface.
FE Slice 2 backend prereq (per-chunk seq). typecheck clean, 469 vitest, biome clean.
| -rw-r--r-- | GLOSSARY.md | 2 | ||||
| -rw-r--r-- | packages/conversation-store/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 10 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 185 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 85 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/conversation.ts | 1 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/index.ts | 1 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 15 | ||||
| -rw-r--r-- | packages/wire/src/index.ts | 16 | ||||
| -rw-r--r-- | tasks.md | 23 |
10 files changed, 321 insertions, 18 deletions
diff --git a/GLOSSARY.md b/GLOSSARY.md index fa94a36..42cd557 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -19,6 +19,8 @@ | **step** | One LLM round-trip within a turn (may emit multiple tool calls). | iteration | | **tool call** | A model's request to run a tool within a step. | function call (when meaning a tool call) | | **chunk** | One ordered piece of a message (text, thinking, tool-call/result, etc.), append-only in the log. | block, segment | +| **seq** | The monotonic, gap-free, per-conversation sequence number stamped on each chunk as it is appended to the log. The sync cursor: a client requests `?sinceSeq=N` to fetch only newer chunks. Storage/sync metadata, never message content. | cursor (when meaning the number), offset, index | +| **StoredChunk** | The wire envelope `{ seq, role, chunk }`: a persisted chunk plus its sync metadata. Keeps the pure `chunk` free of storage concerns while a flat seq-ordered stream stays both syncable and regroupable into messages. | seq'd chunk | | **runTurn** | The kernel's turn loop: takes provider + messages + tools + dispatch policy, streams, dispatches tools, emits events. | run, agentLoop | | **hook** | A typed extension point. **event** = fire-and-forget, N listeners, error-isolated. **filter** = ordered value-in→value-out chain, in-band. | callback (when meaning a hook), listener | | **service** | A single-responder request/response capability fetched via a typed handle. NOT a hook. | — | diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts index 46b281e..f9c0ccc 100644 --- a/packages/conversation-store/src/index.ts +++ b/packages/conversation-store/src/index.ts @@ -1,3 +1,4 @@ +export type { StoredChunk } from "@dispatch/kernel"; export { extension, manifest } from "./extension.js"; export { reconcile } from "./reconcile.js"; export type { ConversationStore } from "./store.js"; diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 5829a7c..8646a40 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -4,12 +4,12 @@ export function seqKey(conversationId: string): string { return `conv:${conversationId}:seq`; } -export function msgKey(conversationId: string, seq: number): string { - return `conv:${conversationId}:msg:${String(seq).padStart(SEQ_PAD, "0")}`; +export function chunkKey(conversationId: string, seq: number): string { + return `conv:${conversationId}:chunk:${String(seq).padStart(SEQ_PAD, "0")}`; } -export function msgPrefix(conversationId: string): string { - return `conv:${conversationId}:msg:`; +export function chunkPrefix(conversationId: string): string { + return `conv:${conversationId}:chunk:`; } export function parseSeq(raw: string | null): number { @@ -18,7 +18,7 @@ export function parseSeq(raw: string | null): number { return Number.isNaN(n) ? 0 : n; } -export function parseMsgSeq(key: string): number { +export function parseChunkSeq(key: string): number { const parts = key.split(":"); const last = parts[parts.length - 1]; if (last === undefined) return -1; diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 60ebeb5..4257d2b 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -149,4 +149,189 @@ describe("ConversationStore", () => { const result = await store.load("conv1"); expect(result).toEqual(messages); }); + + it("append assigns gap-free 1-based per-chunk seq", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "first" }, + { type: "thinking", text: "hmm" }, + { type: "text", text: "second" }, + ], + }; + await store.append("conv1", [msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(3); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + }); + + it("seq continues monotonically across separate append calls", async () => { + const store = createConversationStore(storage); + const msg1: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + const msg2: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "c" }, + { type: "text", text: "d" }, + { type: "text", text: "e" }, + ], + }; + await store.append("conv1", [msg1]); + await store.append("conv1", [msg2]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(5); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + expect(chunks[3]?.seq).toBe(4); + expect(chunks[4]?.seq).toBe(5); + }); + + it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "world" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" }); + }); + + it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "b" }] }, + { role: "user", chunks: [{ type: "text", text: "c" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1", 2); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBe(3); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" }); + }); + + it("load() round-trips the exact ChatMessage[] that was appended", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "read file" }] }, + { + role: "assistant", + chunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "I will read it" }, + { + type: "tool-call", + toolCallId: "call_rt", + toolName: "readFile", + input: { path: "/tmp/x" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_rt", + toolName: "readFile", + content: "file contents here", + isError: false, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + }); + + it("load() does not merge consecutive same-role messages", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "first user msg" }] }, + { role: "user", chunks: [{ type: "text", text: "second user msg" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result).toEqual(messages); + expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe( + "first user msg", + ); + expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe( + "second user msg", + ); + }); + + it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "calling tool" }, + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "someTool", + input: { x: 1 }, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result[2]?.role).toBe("tool"); + const chunk = result[2]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.toolCallId).toBe("call_orphan"); + expect(chunk.isError).toBe(true); + expect(chunk.content).toBe("interrupted: tool execution did not complete"); + } + }); + + it("loadSince returns empty array for unknown conversation", async () => { + const store = createConversationStore(storage); + const result = await store.loadSince("nonexistent"); + expect(result).toEqual([]); + }); + + it("loadSince(0) returns all chunks", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + await store.append("conv1", [msg]); + const all = await store.loadSince("conv1", 0); + expect(all).toHaveLength(2); + expect(all[0]?.seq).toBe(1); + expect(all[1]?.seq).toBe(2); + }); }); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 694833f..df8bd4e 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,43 +1,104 @@ -import type { ChatMessage, StorageNamespace } from "@dispatch/kernel"; +import type { ChatMessage, Chunk, Role, StorageNamespace, StoredChunk } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; -import { msgKey, msgPrefix, parseSeq, seqKey } from "./keys.js"; +import { chunkKey, chunkPrefix, parseSeq, seqKey } from "./keys.js"; import { reconcile } from "./reconcile.js"; export interface ConversationStore { readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; readonly load: (conversationId: string) => Promise<ChatMessage[]>; + readonly loadSince: ( + conversationId: string, + sinceSeq?: number, + ) => Promise<readonly StoredChunk[]>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); +interface PersistedChunkEntry { + readonly chunk: Chunk; + readonly role: Role; + readonly msgIdx: number; + readonly chunkIdx: number; +} + export function createConversationStore(storage: StorageNamespace): ConversationStore { return { async append(conversationId, messages) { const raw = await storage.get(seqKey(conversationId)); - let seq = parseSeq(raw); + let seq = parseSeq(raw) + 1; - for (const msg of messages) { - await storage.set(msgKey(conversationId, seq), JSON.stringify(msg)); - seq++; + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const msg = messages[msgIdx]; + if (msg === undefined) continue; + for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { + const chunk = msg.chunks[chunkIdx]; + if (chunk === undefined) continue; + const entry: PersistedChunkEntry = { + chunk, + role: msg.role, + msgIdx, + chunkIdx, + }; + await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); + seq++; + } } - await storage.set(seqKey(conversationId), String(seq)); + await storage.set(seqKey(conversationId), String(seq - 1)); }, async load(conversationId) { - const prefix = msgPrefix(conversationId); + const prefix = chunkPrefix(conversationId); const keys = await storage.keys(prefix); const sorted = [...keys].sort(); - const raw: ChatMessage[] = []; + const messages: ChatMessage[] = []; + let currentChunks: Chunk[] = []; + let currentRole: Role | undefined; + let currentMsgIdx = -1; + for (const key of sorted) { const value = await storage.get(key); - if (value !== null) { - raw.push(JSON.parse(value) as ChatMessage); + if (value === null) continue; + const entry = JSON.parse(value) as PersistedChunkEntry; + + if (entry.msgIdx !== currentMsgIdx) { + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + currentChunks = []; + currentRole = entry.role; + currentMsgIdx = entry.msgIdx; } + + currentChunks.push(entry.chunk); + } + + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + + return reconcile(messages); + }, + + async loadSince(conversationId, sinceSeq) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: StoredChunk[] = []; + const minSeq = sinceSeq ?? 0; + + for (const key of sorted) { + const seq = parseSeq(key.split(":").pop() ?? null); + if (seq <= minSeq) continue; + const value = await storage.get(key); + if (value === null) continue; + const entry = JSON.parse(value) as PersistedChunkEntry; + result.push({ seq, role: entry.role, chunk: entry.chunk }); } - return reconcile(raw); + return result; }, }; } diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index ec9a389..0080964 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -11,6 +11,7 @@ export type { ErrorChunk, Role, StepId, + StoredChunk, SystemChunk, TextChunk, ThinkingChunk, diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts index 6f6954e..1698486 100644 --- a/packages/kernel/src/contracts/index.ts +++ b/packages/kernel/src/contracts/index.ts @@ -18,6 +18,7 @@ export type { ErrorChunk, Role, StepId, + StoredChunk, SystemChunk, TextChunk, ThinkingChunk, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index d381d6c..39c95d5 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -6,6 +6,7 @@ import type { ProviderEvent, RunTurnInput, RunTurnResult, + StoredChunk, } from "@dispatch/kernel"; import { runTurn } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; @@ -24,6 +25,20 @@ function createInMemoryStore(): ConversationStore & { async load(conversationId) { return [...(data.get(conversationId) ?? [])]; }, + async loadSince(conversationId, sinceSeq) { + const messages = data.get(conversationId) ?? []; + const result: StoredChunk[] = []; + let seq = 1; + for (const msg of messages) { + for (const chunk of msg.chunks) { + if (sinceSeq === undefined || seq > sinceSeq) { + result.push({ seq, role: msg.role, chunk }); + } + seq++; + } + } + return result; + }, }; } diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index d2ea341..82fb3ed 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -92,6 +92,22 @@ export interface ChatMessage { 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 ────────────────────────────────────────────────────────────────── /** @@ -371,7 +371,28 @@ streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../ orchestrator build wiring (new pkg scaffold, project refs, deps). typecheck + biome clean; **460 vitest + 77 bun** (unchanged — pure type move). Summon: prompts/b2-wire-split.md, report: reports/b2-wire-split.md. -- [ ] **per-chunk `seq`** on `Chunk` (wire) — monotonic cursor for mid-turn incremental sync. +- [x] **per-chunk `seq`** on the wire — monotonic cursor for incremental sync. DONE + verified. + Design (user-confirmed): envelope `StoredChunk { seq, role, chunk }` in `@dispatch/wire` + (keeps `Chunk` pure / provider-facing — `Chunk` never carries a cursor); `seq` is monotonic, + gap-free, 1-based, per-conversation, **chunk-granular** (rekeyed from the old per-message seq). + - **Wire/contract (orchestrator):** added `StoredChunk` to `@dispatch/wire` + threaded through + the two kernel re-export shims (`contracts/conversation.ts`, `contracts/index.ts`). Additive + (minor, §2.9). GLOSSARY: added `seq`, `StoredChunk`. + - **conversation-store (owner, mimo-v2.5-pro):** rekeyed `conv:<id>:msg:<seq>` → + `conv:<id>:chunk:<seq>`; `append` explodes messages → role-tagged seq'd chunks (stores + internal `msgIdx`/`chunkIdx` for lossless boundary reconstruction — NOT exposed); `load()` + signature + behaviour UNCHANGED (round-trips exact `ChatMessage[]`, no same-role merge, still + reconciles); NEW `loadSince(conversationId, sinceSeq?) → readonly StoredChunk[]` (raw + persisted stream, ascending, `seq > sinceSeq`; reconciliation deferred to the read-side + endpoint step). +8 store tests. prompts/seq-conversation-store.md, reports/conversation-store.md. + - **Fan-out (session-orchestrator owner):** its in-memory `ConversationStore` test fake grew + `loadSince` to satisfy the additive interface (full `tsc -b` caught this; the agent's + per-package typecheck did not — re-verify the WHOLE build, §4). Test-fake conformance only; + production logic unchanged. prompts/seq-session-orchestrator-fanout.md. + - **Verified (orchestrator):** typecheck clean (EXIT 0), **469 vitest** (460→+9), biome clean, + 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). - [ ] **WS turn-deltas** — `transport-ws` multiplexes `sendMessage`/`onDelta(AgentEvent)` |
