summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 19:39:05 +0900
committerAdam Malczewski <[email protected]>2026-06-06 19:39:05 +0900
commit44e27177892a48a51c440676ff3f6613deef5164 (patch)
treec4e11755df86b1284e3b12b46d25ab4c62b81860
parent22936857685c318b71752d625808100b1a96e63e (diff)
downloaddispatch-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.md2
-rw-r--r--packages/conversation-store/src/index.ts1
-rw-r--r--packages/conversation-store/src/keys.ts10
-rw-r--r--packages/conversation-store/src/store.test.ts185
-rw-r--r--packages/conversation-store/src/store.ts85
-rw-r--r--packages/kernel/src/contracts/conversation.ts1
-rw-r--r--packages/kernel/src/contracts/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts15
-rw-r--r--packages/wire/src/index.ts16
-rw-r--r--tasks.md23
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 ──────────────────────────────────────────────────────────────────
/**
diff --git a/tasks.md b/tasks.md
index ba08cf3..8f27735 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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)`