diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 23:14:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 23:14:55 +0900 |
| commit | 624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 (patch) | |
| tree | 869d34092345344ff13953398f876c8b38c8116a /packages/core | |
| parent | b19f1aafc43141a865ecd40a813ed3212e77d95e (diff) | |
| download | dispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.tar.gz dispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.zip | |
feat(chunks): chunk-native frontend store with turn-sealed reconcile + per-chunk eviction
Replace the stored ChatMessage[] with a chunk-native model: tab.chunks (sealed
ChunkRow[]) + tab.live (transient in-flight turn buffer) + derived tab.renderGroups.
This enables per-chunk eviction (trimming WITHIN a large turn) and raw-chunk
pagination (loadOlderChunks), removing the whole-message eviction limitation.
Backend:
- Emit turn-start/turn-sealed around each turn; expose currentTurnId in the status
snapshot. turn-sealed fires after the durable write (status:idle fires before it).
- New GET /tabs/:id/chunks raw paginated endpoint (limit/before).
- Wrap appendChunks in a single SQLite transaction.
Frontend:
- turn-sealed drives a turn-aware reconcile that folds the sealed turn into chunks
while preserving a concurrent newer in-flight turn and pending queued messages;
deferred while the user is scrolled up.
- Stable turn-scoped render keys (${turnId}:${role}:${n}) avoid remount/flash.
Reconcile correctness (three review passes):
- preserve a concurrent newer turn when an earlier deferred reconcile flushes;
- keep optimistic queued user messages (no loss);
- turn-start backfill skips pending queued rows and tags only the turn initiator;
- bind consumed interrupt messages to the in-flight turn so they collapse on seal
(no lingering/duplicated bubble).
Tests: chat-store reconcile/eviction/pagination suite; api chunks endpoint + events.
Diffstat (limited to 'packages/core')
| -rw-r--r-- | packages/core/src/chunks/append.ts | 13 | ||||
| -rw-r--r-- | packages/core/src/db/chunks.ts | 58 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 26 |
3 files changed, 65 insertions, 32 deletions
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts index baccd10..4bc6b5f 100644 --- a/packages/core/src/chunks/append.ts +++ b/packages/core/src/chunks/append.ts @@ -6,7 +6,7 @@ * backend (agent + agent-manager) and the frontend store call this helper * so the wire format stays in lockstep across the boundary. * - * Open/close rules — see plan-chunk-refactor.md for the full table. + * Open/close rules — see notes/plan-chunk-refactor.md for the full table. * * | Chunk | Opens on | Coalesces | * |---------------|-----------------------------------------------------------------|------------------------------------------------------------| @@ -35,9 +35,10 @@ * (no unsealed thinking chunk) are dropped. * * Ignored events: - * - `status`, `done`, `usage`, `task-list-update`, `tab-created`, - * `message-queued`, `message-consumed`, `message-cancelled` — these are - * control / lifecycle events, not message content. + * - `status`, `turn-start`, `turn-sealed`, `done`, `usage`, + * `task-list-update`, `tab-created`, `message-queued`, `message-consumed`, + * `message-cancelled` — these are control / lifecycle events, not message + * content. */ import type { @@ -200,6 +201,8 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void { // Lifecycle / control events — no chunk emitted. case "status": + case "turn-start": + case "turn-sealed": case "done": case "usage": case "task-list-update": @@ -251,7 +254,7 @@ export interface SystemEventLike { * in flight*. (When a turn IS in flight, the caller should instead use * `appendEventToChunks` against the in-flight message's chunks directly.) * - * Routing rules (from plan-chunk-refactor.md): + * Routing rules (from notes/plan-chunk-refactor.md): * * 1. Most recent message is `role: "system"` → append a `system` chunk * to it. (Note: a second consecutive system event creates a second diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts index 6841eb5..077259d 100644 --- a/packages/core/src/db/chunks.ts +++ b/packages/core/src/db/chunks.ts @@ -52,32 +52,38 @@ export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`, ); const out: ChunkRow[] = []; - for (const draft of drafts) { - const id = randomUUID(); - insert.run({ - $id: id, - $tabId: tabId, - $seq: seq, - $turnId: draft.turnId, - $step: draft.step, - $role: draft.role, - $type: draft.type, - $dataJson: JSON.stringify(draft.data), - $now: now, - }); - out.push({ - id, - tabId, - seq, - turnId: draft.turnId, - step: draft.step, - role: draft.role, - type: draft.type, - data: draft.data, - createdAt: now, - }); - seq++; - } + // Wrap the whole batch in one transaction: a turn's chunks are persisted in + // a single `appendChunks` call, so this is one fsync per turn instead of one + // per row — the chosen low-IO write strategy for constrained backends. + const insertAll = db.transaction(() => { + for (const draft of drafts) { + const id = randomUUID(); + insert.run({ + $id: id, + $tabId: tabId, + $seq: seq, + $turnId: draft.turnId, + $step: draft.step, + $role: draft.role, + $type: draft.type, + $dataJson: JSON.stringify(draft.data), + $now: now, + }); + out.push({ + id, + tabId, + seq, + turnId: draft.turnId, + step: draft.step, + role: draft.role, + type: draft.type, + data: draft.data, + createdAt: now, + }); + seq++; + } + }); + insertAll(); return out; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 3fcdb40..a4230ca 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -10,7 +10,7 @@ export type MessageRole = "user" | "assistant" | "system"; * preserves the actual temporal ordering of text, reasoning, tool calls, * system notices, and errors as they arrived from the model. * - * Coalescing rules (see plan-chunk-refactor.md): + * Coalescing rules (see notes/plan-chunk-refactor.md): * - `text` and `thinking` coalesce on consecutive same-type deltas. * - `tool-batch` coalesces on consecutive `tool-call` events * (appends a new entry to `calls`). @@ -199,10 +199,34 @@ export interface TabStatusSnapshot { status: AgentStatus; currentChunks?: Chunk[]; currentAssistantId?: string; + /** + * `turn_id` of the in-flight turn. Present iff `status === "running"`. + * Lets a frontend that reconnects mid-stream key its live chunks the same + * way `turn-start` would, so they reconcile cleanly when the turn seals. + */ + currentTurnId?: string; } export type AgentEvent = | { type: "status"; status: AgentStatus } + /** + * Emitted once at the start of a turn (`processMessage`), before any + * content deltas. Carries the `turn_id` shared by this turn's user message + * and every assistant/tool chunk row. The frontend tags its in-flight + * (live) chunks with this id so they key-match the sealed rows on + * turn-completion reconcile (no remount/flicker). Display/sync only — not + * conversation content. + */ + | { type: "turn-start"; turnId: string } + /** + * Emitted once after a turn has fully settled AND its chunks have been + * persisted (after `flushAssistant`). Signals the frontend that the turn's + * rows — with real `seq`s — are now durable and can be reloaded, so it can + * fold its transient live representation into the sealed chunk log. Emitted + * after `status: idle`/`error` (which fire before the DB write). Display/sync + * only — not conversation content. + */ + | { type: "turn-sealed"; turnId: string } | { type: "text-delta"; delta: string } | { type: "reasoning-delta"; delta: string } /** |
