summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/groups.ts
blob: 6e0f8eadb0c7a4f6dcd740b6f0ba2c69dfa5c2e8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire";
import type { RenderedChunk } from "./types";

/** One tool call and its response. Null call = unloaded history; null result = pending. */
export interface ToolEntry {
  readonly recovering?: boolean;
  readonly toolCallId: string;
  readonly toolName: string;
  readonly call: ToolCallChunk | null;
  readonly result: ToolResultChunk | null;
}

export type RenderGroup =
  | { readonly kind: "single"; readonly chunk: RenderedChunk }
  | {
      readonly kind: "tool";
      readonly entry: ToolEntry;
      readonly provisional: boolean;
      readonly stepId?: string;
      readonly turnId?: string;
    };

/**
 * One independent group per call, at the call's position, absorbing its result
 * by toolCallId (including legacy chunks without stepId). Orphan results stay
 * visible at their own position. First occurrence wins for duplicate ids, so
 * the stable t<toolCallId> render keys are always unique, even on corrupt replay.
 * Pure: input → output; no DOM, effects, or Svelte.
 */
export function groupRenderedChunks(
  rendered: readonly RenderedChunk[],
  recovering = false,
): readonly RenderGroup[] {
  const calls = new Map<string, RenderedChunk>();
  const results = new Map<string, RenderedChunk>();
  for (const rc of rendered) {
    const c = rc.chunk;
    if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc);
    if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc);
  }

  const groups: RenderGroup[] = [];
  const emitted = new Set<string>();
  for (const rc of rendered) {
    const c = rc.chunk;
    if (c.type !== "tool-call" && c.type !== "tool-result") {
      groups.push({ kind: "single", chunk: rc });
      continue;
    }
    if (emitted.has(c.toolCallId)) continue;
    // A matching call owns the card's position, even if the result came first.
    if (c.type === "tool-result" && calls.has(c.toolCallId)) continue;
    emitted.add(c.toolCallId);
    const callRow = calls.get(c.toolCallId);
    const resultRow = results.get(c.toolCallId);
    const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null;
    const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null;
    const stepId = call?.stepId ?? result?.stepId;
    const turnId = callRow?.turnId ?? resultRow?.turnId;
    groups.push({
      kind: "tool",
      entry: {
        toolCallId: c.toolCallId,
        toolName: call?.toolName ?? c.toolName,
        call,
        result,
        ...(recovering && call === null && resultRow?.provisional ? { recovering: true } : {}),
      },
      provisional: Boolean(callRow?.provisional || resultRow?.provisional),
      ...(stepId !== undefined ? { stepId } : {}),
      ...(turnId !== undefined ? { turnId } : {}),
    });
  }
  return groups;
}