summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/chunks
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/chunks
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/chunks')
-rw-r--r--packages/core/src/chunks/append.ts314
-rw-r--r--packages/core/src/chunks/transform.ts271
2 files changed, 0 insertions, 585 deletions
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
deleted file mode 100644
index 4ca6fe1..0000000
--- a/packages/core/src/chunks/append.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-/**
- * Chunk-builder helper.
- *
- * `appendEventToChunks` is the single source of truth for how a stream of
- * `AgentEvent`s collapses into an ordered `Chunk[]` on a message. Both the
- * 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 notes/plan-chunk-refactor.md for the full table.
- *
- * | Chunk | Opens on | Coalesces |
- * |---------------|-----------------------------------------------------------------|------------------------------------------------------------|
- * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
- * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
- * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) |
- * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
- * | `error` | every `error` event | NEVER (always single-event) |
- * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
- *
- * Side-effect events (no new chunk):
- * - `tool-result` → finds the call by `id` across all `tool-batch`
- * chunks (most-recent first) and updates its
- * `result` / `isError`.
- * - `shell-output` → appends to the most recent entry of the most
- * recent `tool-batch` chunk.
- * - `reasoning-end` → attaches `metadata` (the AI SDK v6
- * `providerMetadata` blob) to the most recent
- * UNSEALED `thinking` chunk. The metadata is also
- * the "sealed" marker — subsequent
- * `reasoning-delta`s will open a new chunk rather
- * than extending this one. Anthropic's signature
- * lives inside this blob; round-tripping it on the
- * next turn is mandatory for Anthropic to accept
- * the conversation. Orphan `reasoning-end` events
- * (no unsealed thinking chunk) are dropped.
- *
- * Ignored events:
- * - `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 {
- AgentEvent,
- ChatMessage,
- Chunk,
- MessageRole,
- SystemChunk,
- SystemChunkKind,
- ToolBatchChunk,
-} from "../types/index.js";
-
-/**
- * Mutates `chunks` in place based on `event`.
- *
- * Returns void; the array is the output channel.
- */
-export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
- switch (event.type) {
- case "text-delta": {
- // Open or extend the current text chunk.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "text") {
- last.text += event.delta;
- } else {
- chunks.push({ type: "text", text: event.delta });
- }
- return;
- }
-
- case "reasoning-delta": {
- // Open a new thinking chunk if the last chunk is not a thinking
- // chunk OR if it's already sealed by metadata. Anthropic emits
- // each thinking content block with its own metadata; a fresh
- // reasoning-delta after a sealed thinking chunk is the start of
- // a new block, not a continuation — extending the sealed chunk
- // would corrupt the metadata/text mapping.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "thinking" && last.metadata === undefined) {
- last.text += event.delta;
- } else {
- chunks.push({ type: "thinking", text: event.delta });
- }
- return;
- }
-
- case "reasoning-end": {
- // Attach `providerMetadata` to the most recent unsealed
- // thinking chunk. Anthropic's signature lives inside this
- // blob; without it on the next request, Anthropic rejects the
- // thinking block. The walk-back is a defensive backstop —
- // Anthropic's SSE delivers a content block's deltas strictly
- // in order and `appendEventToChunks` runs synchronously per
- // event, so the most recent thinking chunk is normally the
- // last chunk in the array.
- if (event.metadata === undefined) return;
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "thinking") continue;
- if (c.metadata !== undefined) {
- // Already sealed; the orphan metadata has no home.
- return;
- }
- c.metadata = event.metadata;
- return;
- }
- // No thinking chunk found at all — drop silently.
- return;
- }
-
- case "tool-call": {
- // Open or extend the current tool-batch chunk.
- const last = chunks[chunks.length - 1];
- const entry = {
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- };
- if (last && last.type === "tool-batch") {
- last.calls.push(entry);
- } else {
- chunks.push({ type: "tool-batch", calls: [entry] });
- }
- return;
- }
-
- case "tool-result": {
- // Find the matching call (by id) across all tool-batch chunks,
- // most-recent first. Tool results can arrive after subsequent
- // text-deltas, so we cannot rely on the *last* chunk being the
- // tool-batch — we have to search.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const call = c.calls.find((e) => e.id === event.toolResult.toolCallId);
- if (call) {
- call.result = event.toolResult.result;
- call.isError = event.toolResult.isError;
- return;
- }
- }
- // Orphan result with no matching call — drop silently.
- return;
- }
-
- case "shell-output": {
- // Append to the most recent entry of the most recent tool-batch.
- // Walk back through chunks to find the latest tool-batch; if there
- // are intervening text/thinking/etc chunks (which can happen if
- // the model streams text while a shell tool is still running),
- // we still want the most recent tool-batch.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const entry = c.calls[c.calls.length - 1];
- if (!entry) return;
- const prev = entry.shellOutput ?? { stdout: "", stderr: "" };
- entry.shellOutput = {
- stdout: prev.stdout + (event.stream === "stdout" ? event.data : ""),
- stderr: prev.stderr + (event.stream === "stderr" ? event.data : ""),
- };
- return;
- }
- // Orphan shell-output with no tool-batch in scope — drop silently.
- return;
- }
-
- case "error": {
- // Always a fresh single-event chunk — no coalescing.
- chunks.push({
- type: "error",
- message: event.error,
- ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
- });
- return;
- }
-
- case "notice": {
- chunks.push({ type: "system", kind: "notice", text: event.message });
- return;
- }
-
- case "model-changed": {
- chunks.push({
- type: "system",
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- return;
- }
-
- case "config-reload": {
- chunks.push({
- type: "system",
- kind: "config-reload",
- text: "Configuration reloaded",
- });
- return;
- }
-
- // Lifecycle / control events — no chunk emitted.
- case "status":
- case "turn-start":
- case "turn-sealed":
- case "done":
- case "usage":
- case "task-list-update":
- case "tab-created":
- case "message-queued":
- case "message-consumed":
- case "message-cancelled":
- case "compaction-started":
- case "compaction-complete":
- case "compaction-error":
- return;
-
- default: {
- // Exhaustiveness check — if a new event variant is added to
- // AgentEvent, TypeScript will complain here.
- const _exhaustive: never = event;
- void _exhaustive;
- return;
- }
- }
-}
-
-// ─── System event routing across messages ────────────────────────
-
-/**
- * Minimal shape needed by `applySystemEvent`.
- *
- * The caller (agent-manager / persistence layer) typically tracks message
- * id alongside the wire-format `ChatMessage`. This generic constraint
- * lets us keep core `ChatMessage` clean while still letting downstream
- * pass anything with an `id`.
- */
-export interface IdentifiedMessage {
- id: string;
- role: MessageRole;
- chunks: Chunk[];
-}
-
-/**
- * Describes the system event in caller-controlled terms. We let the caller
- * decide both the `kind` (so the same helper can record cancellations,
- * notices, model swaps, etc.) and the `text` (so the caller controls
- * formatting / localization).
- */
-export interface SystemEventLike {
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * Routes a system event to the right message when *no assistant turn is
- * 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 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
- * system chunk inside the same system message — chunks themselves
- * never coalesce.)
- * 2. Otherwise → create a fresh `role: "system"` message containing one
- * `system` chunk and push it.
- *
- * Returns the `messageId` that was used (either the existing system
- * message's id or the newly-created one) so the caller can persist /
- * emit a diff to subscribers.
- *
- * `idFactory` defaults to `crypto.randomUUID()`; tests inject a
- * deterministic factory.
- */
-export function applySystemEvent<M extends IdentifiedMessage>(
- messages: M[],
- event: SystemEventLike,
- idFactory: () => string = defaultIdFactory,
-): { messageId: string } {
- const chunk: SystemChunk = { type: "system", kind: event.kind, text: event.text };
-
- const last = messages[messages.length - 1];
- if (last && last.role === "system") {
- last.chunks.push(chunk);
- return { messageId: last.id };
- }
-
- const id = idFactory();
- // We can't fabricate the full `M` shape without knowing its extra
- // fields, but `IdentifiedMessage` is the minimum we need to push.
- // Callers that extend the shape with extra fields are responsible for
- // initializing them via post-hoc patching, or by passing in their own
- // message-creation logic. In practice callers either:
- // (a) use `ChatMessage` itself (no extra fields beyond IdentifiedMessage), or
- // (b) construct messages and look them up by id after this call returns.
- const newMessage = { id, role: "system" as const, chunks: [chunk] } as unknown as M;
- messages.push(newMessage);
- return { messageId: id };
-}
-
-function defaultIdFactory(): string {
- // In Node 19+ / modern browsers, `crypto.randomUUID` is available globally.
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback: pseudo-random; not cryptographically secure, but adequate for
- // in-memory message identifiers when randomUUID is unavailable.
- return `sysmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-// ─── Re-exports for convenience ──────────────────────────────────
-
-export type { ChatMessage, Chunk, SystemChunk, SystemChunkKind, ToolBatchChunk };
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts
deleted file mode 100644
index e8f4a18..0000000
--- a/packages/core/src/chunks/transform.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-// Pure, dependency-free transforms between the render-shaped `Chunk[]` /
-// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any
-// DB (`bun:sqlite`) import so BOTH the backend persistence layer
-// (`db/chunks.ts`) and the browser frontend store can share the exact same
-// explode/group logic.
-
-import type {
- Chunk,
- ChunkRow,
- ChunkRowDraft,
- ErrorData,
- MessageRole,
- SystemData,
- TextData,
- ThinkingData,
- ToolBatchChunk,
- ToolCallData,
- ToolResultData,
-} from "../types/index.js";
-
-/**
- * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the
- * agent's in-memory history and for the frontend's render bubbles. NOT a stored
- * shape: the source of truth is the flat chunk log.
- */
-export interface MessageRow {
- id: string;
- tabId: string;
- seq: number;
- /** turn_id of the chunk rows this message was grouped from. */
- turnId: string;
- role: MessageRole;
- chunks: Chunk[];
- createdAt: number;
-}
-
-// ─── Explode: in-memory turn → flat chunk-row drafts ─────────────
-//
-// A turn's render-shaped `Chunk[]` is flattened into append-only rows.
-// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and
-// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format. `step` increments after each tool-batch: every LLM
-// round-trip emits text/thinking then (optionally) one tool-batch, so a
-// tool-batch boundary is exactly a step boundary.
-
-/** Explode a single user message's text into one row draft. */
-export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] {
- return [{ turnId, step: 0, role: "user", type: "text", data: { text } }];
-}
-
-/** Explode an assistant turn's accumulated chunks into ordered row drafts. */
-export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] {
- const drafts: ChunkRowDraft[] = [];
- let step = 0;
- for (const chunk of chunks) {
- switch (chunk.type) {
- case "text":
- drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } });
- break;
- case "thinking":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "thinking",
- data: {
- text: chunk.text,
- ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}),
- },
- });
- break;
- case "tool-batch": {
- for (const call of chunk.calls) {
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "tool_call",
- data: { callId: call.id, name: call.name, arguments: call.arguments },
- });
- }
- for (const call of chunk.calls) {
- if (call.result === undefined) continue;
- drafts.push({
- turnId,
- step,
- role: "tool",
- type: "tool_result",
- data: {
- callId: call.id,
- name: call.name,
- result: call.result,
- isError: call.isError ?? false,
- ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}),
- },
- });
- }
- // A tool-batch ends the current LLM step; subsequent chunks belong
- // to the next round-trip.
- step++;
- break;
- }
- case "error":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "error",
- data: {
- message: chunk.message,
- ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}),
- },
- });
- break;
- case "system":
- drafts.push({
- turnId,
- step,
- role: "system",
- type: "system",
- data: { kind: chunk.kind, text: chunk.text },
- });
- break;
- }
- }
- return drafts;
-}
-
-// ─── Group: flat chunk rows → derived render messages ────────────
-//
-// The inverse of explode (best-effort over an arbitrary window, so it tolerates
-// orphan tool-results whose tool-call was paged out). `tool_result` rows
-// (role=tool) merge back into the preceding assistant message's per-step
-// `tool-batch` chunk by `callId` rather than forming their own message.
-
-export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] {
- const messages: MessageRow[] = [];
-
- let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null;
- const flush = () => {
- if (current) {
- messages.push(current.msg);
- current = null;
- }
- };
- const ensureAssistant = (row: ChunkRow) => {
- if (!current) {
- current = {
- msg: {
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "assistant",
- chunks: [],
- createdAt: row.createdAt,
- },
- batches: new Map(),
- };
- }
- return current;
- };
- const ensureBatch = (step: number): ToolBatchChunk => {
- const c = current;
- if (!c) throw new Error("ensureBatch called without an assistant message");
- let batch = c.batches.get(step);
- if (!batch) {
- batch = { type: "tool-batch", calls: [] };
- c.batches.set(step, batch);
- c.msg.chunks.push(batch);
- }
- return batch;
- };
-
- for (const row of rows) {
- if (row.role === "user") {
- flush();
- const d = row.data as TextData;
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "user",
- chunks: [{ type: "text", text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
- if (row.role === "system") {
- // Coalesce consecutive system rows into one system message (multiple
- // system chunks), matching the old applySystemEvent behaviour.
- const prev = messages[messages.length - 1];
- const d = row.data as SystemData;
- if (current === null && prev && prev.role === "system") {
- prev.chunks.push({ type: "system", kind: d.kind, text: d.text });
- continue;
- }
- flush();
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "system",
- chunks: [{ type: "system", kind: d.kind, text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
-
- // Usage rows are an invisible side channel (persisted for the backend
- // aggregate only). They're already query-excluded from getChunksForTab,
- // so this is defensive insurance: never let one leak into render grouping.
- if (row.type === "usage") continue;
-
- // assistant / tool rows → part of the current assistant message
- const c = ensureAssistant(row);
- switch (row.type) {
- case "text":
- c.msg.chunks.push({ type: "text", text: (row.data as TextData).text });
- break;
- case "thinking": {
- const d = row.data as ThinkingData;
- c.msg.chunks.push({
- type: "thinking",
- text: d.text,
- ...(d.metadata !== undefined ? { metadata: d.metadata } : {}),
- });
- break;
- }
- case "error": {
- const d = row.data as ErrorData;
- c.msg.chunks.push({
- type: "error",
- message: d.message,
- ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}),
- });
- break;
- }
- case "tool_call": {
- const d = row.data as ToolCallData;
- ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments });
- break;
- }
- case "tool_result": {
- const d = row.data as ToolResultData;
- const batch = ensureBatch(row.step);
- const entry = batch.calls.find((e) => e.id === d.callId);
- if (entry) {
- entry.result = d.result;
- entry.isError = d.isError;
- if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput;
- } else {
- // Orphan result (its tool_call was paged out of this window).
- batch.calls.push({
- id: d.callId,
- name: d.name,
- arguments: {},
- result: d.result,
- isError: d.isError,
- ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}),
- });
- }
- break;
- }
- }
- }
- flush();
- return messages;
-}