summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
committerAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
commit624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 (patch)
tree869d34092345344ff13953398f876c8b38c8116a /packages/core/src
parentb19f1aafc43141a865ecd40a813ed3212e77d95e (diff)
downloaddispatch-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/src')
-rw-r--r--packages/core/src/chunks/append.ts13
-rw-r--r--packages/core/src/db/chunks.ts58
-rw-r--r--packages/core/src/types/index.ts26
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 }
/**