diff options
| author | Adam Malczewski <[email protected]> | 2026-05-27 18:35:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-27 18:35:18 +0900 |
| commit | ca6ee91c5e1167b1929eedbb96c76dfa24e7d026 (patch) | |
| tree | bc23acac2e7caaf2e59eacbc21bfc9b41f3c1458 /packages/core/src/chunks | |
| parent | da57842686ebfd157396551fc76d0c18f7676335 (diff) | |
| download | dispatch-ca6ee91c5e1167b1929eedbb96c76dfa24e7d026.tar.gz dispatch-ca6ee91c5e1167b1929eedbb96c76dfa24e7d026.zip | |
refactor: ChatMessage.chunks[] union — interleaved thinking, tool batching, error/system chunks
Diffstat (limited to 'packages/core/src/chunks')
| -rw-r--r-- | packages/core/src/chunks/append.ts | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts new file mode 100644 index 0000000..9c2a367 --- /dev/null +++ b/packages/core/src/chunks/append.ts @@ -0,0 +1,265 @@ +/** + * 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 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` | + * | `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. + * + * Ignored events: + * - `status`, `done`, `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 or extend the current thinking chunk. + const last = chunks[chunks.length - 1]; + if (last && last.type === "thinking") { + last.text += event.delta; + } else { + chunks.push({ type: "thinking", text: event.delta }); + } + 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 "done": + case "task-list-update": + case "tab-created": + case "message-queued": + case "message-consumed": + case "message-cancelled": + 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 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 }; |
