File size: 4,604 Bytes
5710dd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
/**
 * Structural diff over serialized `ChatState` values (see `serialize.ts`).
 *
 * The audit panel diffs two adjacent, immutable states. Because the store
 * is copy-on-write, untouched subtrees share references — the reference
 * equality fast path below collapses them to `unchanged` without walking.
 *
 * Arrays of protocol entities are matched by their id field (turn_id,
 * step_id, message_id, tool_call_id, …) rather than by index, so an upsert
 * in the middle of the timeline does not turn into a cascade of spurious
 * modifications.
 */

export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified';

export interface DiffNode {
  readonly status: DiffStatus;
  /** Current value (`undefined` when removed). */
  readonly value: unknown;
  /** Previous value (`undefined` when added). */
  readonly prev: unknown;
  /**
   * Object/array children — key is the object key, the entity id, or
   * `#<index>` for plain arrays. Absent on leaves and on whole-subtree
   * added/removed nodes (the renderer colors the subtree as one block).
   */
  readonly children?: ReadonlyMap<string, DiffNode>;
}

/**
 * Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction
 * carries both `interaction_id` and `tool_call_id`, a tool call can carry
 * `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline
 * entity carries `turn_id`; matching the wrong one mislabels the node and,
 * worse, collides siblings in the children map (two tool calls of one task
 * both keyed by that task id).
 */
const ID_FIELDS = [
  'message_id',
  'interaction_id',
  'tool_call_id',
  'task_id',
  'todo_id',
  'system_id',
  'step_id',
  'turn_id',
] as const;

function elementId(element: unknown): string | undefined {
  if (typeof element !== 'object' || element === null) return undefined;
  for (const field of ID_FIELDS) {
    const value = (element as Record<string, unknown>)[field];
    if (typeof value === 'string') return value;
  }
  return undefined;
}

/** Public for the audit UI: same id-priority keying used to match array elements. */
export { elementId };

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function containerStatus(children: ReadonlyMap<string, DiffNode>): DiffStatus {
  for (const child of children.values()) {
    if (child.status !== 'unchanged') return 'modified';
  }
  return 'unchanged';
}

function diffObjects(prev: Record<string, unknown>, next: Record<string, unknown>): DiffNode {
  const children = new Map<string, DiffNode>();
  for (const key of Object.keys(next)) {
    children.set(key, diffValue(prev[key], next[key]));
  }
  for (const key of Object.keys(prev)) {
    if (!(key in next)) {
      children.set(key, { status: 'removed', value: undefined, prev: prev[key] });
    }
  }
  return { status: containerStatus(children), value: next, prev, children };
}

function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode {
  const children = new Map<string, DiffNode>();
  const keyed =
    prev.every((el) => elementId(el) !== undefined) &&
    next.every((el) => elementId(el) !== undefined);

  if (keyed) {
    const prevById = new Map<string, unknown>();
    for (const el of prev) prevById.set(elementId(el) as string, el);
    const nextIds = new Set<string>();
    for (const el of next) {
      const id = elementId(el) as string;
      nextIds.add(id);
      children.set(id, diffValue(prevById.get(id), el));
    }
    for (const el of prev) {
      const id = elementId(el) as string;
      if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el });
    }
  } else {
    for (let i = 0; i < next.length; i += 1) {
      children.set(`#${i}`, diffValue(prev[i], next[i]));
    }
    for (let i = next.length; i < prev.length; i += 1) {
      children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] });
    }
  }
  return { status: containerStatus(children), value: next, prev, children };
}

export function diffValue(prev: unknown, next: unknown): DiffNode {
  if (prev === next) return { status: 'unchanged', value: next, prev };
  if (prev === undefined) return { status: 'added', value: next, prev: undefined };
  if (next === undefined) return { status: 'removed', value: undefined, prev };
  if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next);
  if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next);
  return { status: 'modified', value: next, prev };
}