/ utils / collapseHookSummaries.ts
collapseHookSummaries.ts
 1  import type {
 2    RenderableMessage,
 3    SystemStopHookSummaryMessage,
 4  } from '../types/message.js'
 5  
 6  function isLabeledHookSummary(
 7    msg: RenderableMessage,
 8  ): msg is SystemStopHookSummaryMessage {
 9    return (
10      msg.type === 'system' &&
11      msg.subtype === 'stop_hook_summary' &&
12      msg.hookLabel !== undefined
13    )
14  }
15  
16  /**
17   * Collapses consecutive hook summary messages with the same hookLabel
18   * (e.g. PostToolUse) into a single summary. This happens when parallel
19   * tool calls each emit their own hook summary.
20   */
21  export function collapseHookSummaries(
22    messages: RenderableMessage[],
23  ): RenderableMessage[] {
24    const result: RenderableMessage[] = []
25    let i = 0
26  
27    while (i < messages.length) {
28      const msg = messages[i]!
29      if (isLabeledHookSummary(msg)) {
30        const label = msg.hookLabel
31        const group: SystemStopHookSummaryMessage[] = []
32        while (i < messages.length) {
33          const next = messages[i]!
34          if (!isLabeledHookSummary(next) || next.hookLabel !== label) break
35          group.push(next)
36          i++
37        }
38        if (group.length === 1) {
39          result.push(msg)
40        } else {
41          result.push({
42            ...msg,
43            hookCount: group.reduce((sum, m) => sum + m.hookCount, 0),
44            hookInfos: group.flatMap(m => m.hookInfos),
45            hookErrors: group.flatMap(m => m.hookErrors),
46            preventedContinuation: group.some(m => m.preventedContinuation),
47            hasOutput: group.some(m => m.hasOutput),
48            // Parallel tool calls' hooks overlap; max is closest to wall-clock.
49            totalDurationMs: Math.max(...group.map(m => m.totalDurationMs ?? 0)),
50          })
51        }
52      } else {
53        result.push(msg)
54        i++
55      }
56    }
57  
58    return result
59  }