diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 20:06:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 20:06:31 +0900 |
| commit | 0f39b6f78957aacf206012ad2193d9b0c1940c1f (patch) | |
| tree | ff5f2da8b4f3cdf56cf50d44b8fec75a489ad6fe /packages/frontend/src/lib | |
| parent | 8c58a973b0d021689cebad5c0cc6d56956bbc2f6 (diff) | |
| download | dispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.tar.gz dispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.zip | |
refactor(chunks): append-only chunk log with per-step cache-stable wire
Replace the message-as-container model with a flat, append-only chunk log.
- chunks table (id, tab_id, seq, turn_id, step, role, type, data_json): one
row per chunk; tool_call (assistant) and tool_result (tool) are SEPARATE
rows linked by callId. Message/turn are derived groupings, not stored.
- chunks/transform.ts: DB-free explode (Chunk[] -> rows) / group (rows ->
messages), shared by backend and the browser frontend.
- Cache fix: toModelMessages segments each turn at tool-batch boundaries into
stable [assistant, tool] pairs per step, so earlier steps serialize
byte-identically across requests (kills the prompt-cache churn).
- agent-manager persists a turn's chunks on seal (once), discarding a failed
fallback attempt's partial chunks; rebuilds agent history from the log.
- GET /messages windows the log by chunk seq then groups; loadMoreMessages
merges a turn split across the window boundary by turnId.
- One-shot migration drops the legacy messages table and clears tabs;
settings/credentials/keys/usage preserved.
Full suite green (317 tests); biome, tsc, and svelte-check clean.
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 50 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 7 |
2 files changed, 47 insertions, 10 deletions
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 6e2d157..c57f800 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -230,12 +230,10 @@ export function createTabStore() { }; const messagesRes = await fetch(`${config.apiBase}/tabs/${agentId}/messages?limit=100`); - // The backend's `getMessagesForTab` (packages/core/src/db/messages.ts) - // already parses `content_json` into a `Chunk[]` and serves it as - // `chunks` over the wire — NOT the raw `contentJson` string. Earlier - // versions of this client expected `contentJson` and silently dropped - // every message when JSON.parse(undefined) threw, leaving the UI - // with empty conversations after a refresh. + // `GET /messages` windows the flat chunk log (last N chunks) and + // groups the rows into render messages (`groupRowsToMessages` in + // packages/core/src/chunks/transform.ts), serving them as `chunks` + // per message over the wire — NOT a raw JSON string. const messagesData = messagesRes.ok ? ((await messagesRes.json()) as { messages: Array<{ @@ -408,8 +406,15 @@ export function createTabStore() { const res = await fetch(`${config.apiBase}/tabs/${tabId}/messages?limit=50${beforeParam}`); if (!res.ok) return; const data = (await res.json()) as { - messages?: Array<{ id?: string; role: string; chunks?: Chunk[]; seq?: number }>; + messages?: Array<{ + id?: string; + role: string; + chunks?: Chunk[]; + seq?: number; + turnId?: string; + }>; total?: number; + oldestSeq?: number | null; }; const rawMessages = data.messages ?? []; if (rawMessages.length === 0) { @@ -426,18 +431,43 @@ export function createTabStore() { chunks: Array.isArray(m.chunks) ? m.chunks : [], isStreaming: false, seq: m.seq, + ...(m.turnId !== undefined ? { turnId: m.turnId } : {}), })); const current = getTabById(tabId); if (!current) return; + // Chunk-granular pagination can split ONE turn across the window + // boundary: the oldest message already loaded and the newest message + // in this older page may share a turn_id. Merge them (older chunks + // first) so the turn renders as one bubble instead of duplicating. + const merged = [...current.messages]; + const lastOlder = older[older.length - 1]; + const firstCurrent = merged[0]; + if ( + lastOlder && + firstCurrent && + lastOlder.turnId !== undefined && + lastOlder.turnId === firstCurrent.turnId && + lastOlder.role === firstCurrent.role + ) { + older.pop(); + merged[0] = { + ...firstCurrent, + id: lastOlder.id, + seq: lastOlder.seq, + turnId: lastOlder.turnId, + chunks: [...lastOlder.chunks, ...firstCurrent.chunks], + }; + } + // Avoid duplicating messages we already have loaded. - const existingIds = new Set(current.messages.map((m) => m.id)); + const existingIds = new Set(merged.map((m) => m.id)); const toPrepend = older.filter((m) => !existingIds.has(m.id)); - const newOldestSeq = oldestSeqOf(rawMessages); + const newOldestSeq = data.oldestSeq ?? oldestSeqOf(rawMessages); updateTab(tabId, { - messages: [...toPrepend, ...current.messages], + messages: [...toPrepend, ...merged], oldestLoadedSeq: newOldestSeq ?? current.oldestLoadedSeq, totalMessages: data.total ?? current.totalMessages, }); diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 8c34d69..6e87aec 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -96,6 +96,13 @@ export interface ChatMessage { isStreaming?: boolean; debugInfo?: DebugInfo; seq?: number; + /** + * turn_id of the chunk rows this message was grouped from (history loaded + * from the backend). Used by `loadMoreMessages` to merge a turn that was + * split across the chunk-pagination window boundary. Absent for live + * (streaming) messages built client-side. + */ + turnId?: string; } export type ConnectionStatus = "connecting" | "connected" | "disconnected"; |
