summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
committerAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
commit0f39b6f78957aacf206012ad2193d9b0c1940c1f (patch)
treeff5f2da8b4f3cdf56cf50d44b8fec75a489ad6fe /packages/frontend/src
parent8c58a973b0d021689cebad5c0cc6d56956bbc2f6 (diff)
downloaddispatch-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')
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts50
-rw-r--r--packages/frontend/src/lib/types.ts7
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";