/ services / compact / grouping.ts
grouping.ts
 1  import type { Message } from '../../types/message.js'
 2  
 3  /**
 4   * Groups messages at API-round boundaries: one group per API round-trip.
 5   * A boundary fires when a NEW assistant response begins (different
 6   * message.id from the prior assistant). For well-formed conversations
 7   * this is an API-safe split point — the API contract requires every
 8   * tool_use to be resolved before the next assistant turn, so pairing
 9   * validity falls out of the assistant-id boundary. For malformed inputs
10   * (dangling tool_use after resume/truncation) the fork's
11   * ensureToolResultPairing repairs the split at API time.
12   *
13   * Replaces the prior human-turn grouping (boundaries only at real user
14   * prompts) with finer-grained API-round grouping, allowing reactive
15   * compact to operate on single-prompt agentic sessions (SDK/CCR/eval
16   * callers) where the entire workload is one human turn.
17   *
18   * Extracted to its own file to break the compact.ts ↔ compactMessages.ts
19   * cycle (CC-1180) — the cycle shifted module-init order enough to surface
20   * a latent ws CJS/ESM resolution race in CI shard-2.
21   */
22  export function groupMessagesByApiRound(messages: Message[]): Message[][] {
23    const groups: Message[][] = []
24    let current: Message[] = []
25    // message.id of the most recently seen assistant. This is the sole
26    // boundary gate: streaming chunks from the same API response share an
27    // id, so boundaries only fire at the start of a genuinely new round.
28    // normalizeMessages yields one AssistantMessage per content block, and
29    // StreamingToolExecutor interleaves tool_results between chunks live
30    // (yield order, not concat order — see query.ts:613). The id check
31    // correctly keeps `[tu_A(id=X), result_A, tu_B(id=X)]` in one group.
32    let lastAssistantId: string | undefined
33  
34    // In a well-formed conversation the API contract guarantees every
35    // tool_use is resolved before the next assistant turn, so lastAssistantId
36    // alone is a sufficient boundary gate. Tracking unresolved tool_use IDs
37    // would only do work when the conversation is malformed (dangling tool_use
38    // after resume-from-partial-batch or max_tokens truncation) — and in that
39    // case it pins the gate shut forever, merging all subsequent rounds into
40    // one group. We let those boundaries fire; the summarizer fork's own
41    // ensureToolResultPairing at claude.ts:1136 repairs the dangling tu at
42    // API time.
43    for (const msg of messages) {
44      if (
45        msg.type === 'assistant' &&
46        msg.message.id !== lastAssistantId &&
47        current.length > 0
48      ) {
49        groups.push(current)
50        current = [msg]
51      } else {
52        current.push(msg)
53      }
54      if (msg.type === 'assistant') {
55        lastAssistantId = msg.message.id
56      }
57    }
58  
59    if (current.length > 0) {
60      groups.push(current)
61    }
62    return groups
63  }