@respan/cli 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -438,7 +438,7 @@ function detectModel(hookData) {
438
438
  const llmReq = hookData.llm_request ?? {};
439
439
  return String(llmReq.model ?? "") || "gemini-cli";
440
440
  }
441
- function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurns, toolDetails, thoughtsTokens) {
441
+ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurns, toolDetails, thoughtsTokens, textRounds, roundStartTimes) {
442
442
  const spans = [];
443
443
  const sessionId = String(hookData.session_id ?? "");
444
444
  const model = detectModel(hookData);
@@ -447,7 +447,6 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
447
447
  const beginTime = startTimeIso || endTime;
448
448
  const lat = latencySeconds(beginTime, endTime);
449
449
  const promptMessages = extractMessages(hookData);
450
- const completionMessage = { role: "assistant", content: truncate(outputText, MAX_CHARS) };
451
450
  const { workflowName, spanName, customerId } = resolveSpanFields(config, {
452
451
  workflowName: "gemini-cli",
453
452
  spanName: "gemini-cli"
@@ -480,50 +479,85 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
480
479
  metadata,
481
480
  ...lat !== void 0 ? { latency: lat } : {}
482
481
  });
483
- const genSpan = {
484
- trace_unique_id: traceUniqueId,
485
- span_unique_id: `gcli_${safeId}_${turnTs}_gen`,
486
- span_parent_id: rootSpanId,
487
- span_name: "gemini.chat",
488
- span_workflow_name: workflowName,
489
- span_path: "gemini_chat",
490
- model,
491
- provider_id: "google",
492
- metadata: {},
493
- input: promptMessages.length ? JSON.stringify(promptMessages) : "",
494
- output: truncate(outputText, MAX_CHARS),
495
- timestamp: endTime,
496
- start_time: beginTime,
497
- prompt_tokens: tokens.prompt_tokens,
498
- completion_tokens: tokens.completion_tokens,
499
- total_tokens: tokens.total_tokens,
500
- ...lat !== void 0 ? { latency: lat } : {}
501
- };
502
- if (reqConfig.temperature != null) genSpan.temperature = reqConfig.temperature;
503
- if (reqConfig.maxOutputTokens != null) genSpan.max_tokens = reqConfig.maxOutputTokens;
504
- spans.push(genSpan);
505
- if (thoughtsTokens > 0) {
506
- spans.push({
507
- trace_unique_id: traceUniqueId,
508
- span_unique_id: `gcli_${safeId}_${turnTs}_reasoning`,
509
- span_parent_id: rootSpanId,
510
- span_name: "Reasoning",
511
- span_workflow_name: workflowName,
512
- span_path: "reasoning",
513
- provider_id: "",
514
- metadata: { reasoning_tokens: thoughtsTokens },
515
- input: "",
516
- output: `[Reasoning: ${thoughtsTokens} tokens]`,
517
- timestamp: endTime,
518
- start_time: beginTime
519
- });
482
+ const rounds = textRounds.length > 0 ? textRounds : [outputText];
483
+ const roundStarts = roundStartTimes.length > 0 ? roundStartTimes : [beginTime];
484
+ let toolIdx = 0;
485
+ for (let r = 0; r < rounds.length; r++) {
486
+ const roundText = rounds[r];
487
+ const roundStart = roundStarts[r] || beginTime;
488
+ const nextTool = toolIdx < toolDetails.length ? toolDetails[toolIdx] : null;
489
+ const roundEnd = r < rounds.length - 1 && nextTool?.start_time ? nextTool.start_time : endTime;
490
+ const roundLat = latencySeconds(roundStart, roundEnd);
491
+ if (roundText) {
492
+ const genSpan = {
493
+ trace_unique_id: traceUniqueId,
494
+ span_unique_id: `gcli_${safeId}_${turnTs}_gen_${r}`,
495
+ span_parent_id: rootSpanId,
496
+ span_name: "gemini.chat",
497
+ span_workflow_name: workflowName,
498
+ span_path: "gemini_chat",
499
+ model,
500
+ provider_id: "google",
501
+ metadata: {},
502
+ input: r === 0 && promptMessages.length ? JSON.stringify(promptMessages) : "",
503
+ output: truncate(roundText, MAX_CHARS),
504
+ timestamp: roundEnd,
505
+ start_time: roundStart,
506
+ ...roundLat !== void 0 ? { latency: roundLat } : {},
507
+ // Only attach tokens to the first round (aggregate usage from Gemini)
508
+ ...r === 0 ? {
509
+ prompt_tokens: tokens.prompt_tokens,
510
+ completion_tokens: tokens.completion_tokens,
511
+ total_tokens: tokens.total_tokens
512
+ } : {}
513
+ };
514
+ if (r === 0) {
515
+ if (reqConfig.temperature != null) genSpan.temperature = reqConfig.temperature;
516
+ if (reqConfig.maxOutputTokens != null) genSpan.max_tokens = reqConfig.maxOutputTokens;
517
+ }
518
+ spans.push(genSpan);
519
+ }
520
+ if (r < rounds.length - 1) {
521
+ while (toolIdx < toolDetails.length) {
522
+ const detail = toolDetails[toolIdx];
523
+ const toolName = detail?.name ?? "";
524
+ const toolArgs = detail?.args ?? detail?.input ?? {};
525
+ const toolOutput = detail?.output ?? "";
526
+ const displayName = toolName ? toolDisplayName(toolName) : `Call ${toolIdx + 1}`;
527
+ const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : "";
528
+ const toolMeta = {};
529
+ if (toolName) toolMeta.tool_name = toolName;
530
+ if (detail?.error) toolMeta.error = detail.error;
531
+ const toolStart = detail?.start_time ?? beginTime;
532
+ const toolEnd = detail?.end_time ?? endTime;
533
+ const toolLat = latencySeconds(toolStart, toolEnd);
534
+ spans.push({
535
+ trace_unique_id: traceUniqueId,
536
+ span_unique_id: `gcli_${safeId}_${turnTs}_tool_${toolIdx + 1}`,
537
+ span_parent_id: rootSpanId,
538
+ span_name: `Tool: ${displayName}`,
539
+ span_workflow_name: workflowName,
540
+ span_path: toolName ? `tool_${toolName}` : "tool_call",
541
+ provider_id: "",
542
+ metadata: toolMeta,
543
+ input: toolInputStr,
544
+ output: truncate(toolOutput, MAX_CHARS),
545
+ timestamp: toolEnd,
546
+ start_time: toolStart,
547
+ ...toolLat !== void 0 ? { latency: toolLat } : {}
548
+ });
549
+ toolIdx++;
550
+ const nextDetail = toolDetails[toolIdx];
551
+ if (nextDetail && roundStarts[r + 1] && nextDetail.start_time && nextDetail.start_time > roundStarts[r + 1]) break;
552
+ }
553
+ }
520
554
  }
521
- for (let i = 0; i < toolTurns; i++) {
522
- const detail = toolDetails[i] ?? null;
555
+ while (toolIdx < toolDetails.length) {
556
+ const detail = toolDetails[toolIdx];
523
557
  const toolName = detail?.name ?? "";
524
558
  const toolArgs = detail?.args ?? detail?.input ?? {};
525
559
  const toolOutput = detail?.output ?? "";
526
- const displayName = toolName ? toolDisplayName(toolName) : `Call ${i + 1}`;
560
+ const displayName = toolName ? toolDisplayName(toolName) : `Call ${toolIdx + 1}`;
527
561
  const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : "";
528
562
  const toolMeta = {};
529
563
  if (toolName) toolMeta.tool_name = toolName;
@@ -533,7 +567,7 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
533
567
  const toolLat = latencySeconds(toolStart, toolEnd);
534
568
  spans.push({
535
569
  trace_unique_id: traceUniqueId,
536
- span_unique_id: `gcli_${safeId}_${turnTs}_tool_${i + 1}`,
570
+ span_unique_id: `gcli_${safeId}_${turnTs}_tool_${toolIdx + 1}`,
537
571
  span_parent_id: rootSpanId,
538
572
  span_name: `Tool: ${displayName}`,
539
573
  span_workflow_name: workflowName,
@@ -546,6 +580,23 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
546
580
  start_time: toolStart,
547
581
  ...toolLat !== void 0 ? { latency: toolLat } : {}
548
582
  });
583
+ toolIdx++;
584
+ }
585
+ if (thoughtsTokens > 0) {
586
+ spans.push({
587
+ trace_unique_id: traceUniqueId,
588
+ span_unique_id: `gcli_${safeId}_${turnTs}_reasoning`,
589
+ span_parent_id: rootSpanId,
590
+ span_name: "Reasoning",
591
+ span_workflow_name: workflowName,
592
+ span_path: "reasoning",
593
+ provider_id: "",
594
+ metadata: { reasoning_tokens: thoughtsTokens },
595
+ input: "",
596
+ output: `[Reasoning: ${thoughtsTokens} tokens]`,
597
+ timestamp: endTime,
598
+ start_time: beginTime
599
+ });
549
600
  }
550
601
  return addDefaultsToAll(spans);
551
602
  }
@@ -664,6 +715,8 @@ function processBeforeTool(hookData) {
664
715
  const pending = state.pending_tools ?? [];
665
716
  pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
666
717
  state.pending_tools = pending;
718
+ state.send_version = (state.send_version ?? 0) + 1;
719
+ state.tool_turns = (state.tool_turns ?? 0) + 1;
667
720
  saveStreamState(sessionId, state);
668
721
  }
669
722
  function processAfterTool(hookData) {
@@ -746,7 +799,8 @@ function processChunk(hookData) {
746
799
  state.tool_turns = (state.tool_turns ?? 0) + 1;
747
800
  state.send_version = (state.send_version ?? 0) + 1;
748
801
  toolCallDetected = true;
749
- debug(`Tool call detected via msg_count (${savedMsgCount} \u2192 ${currentMsgCount}), tool_turns=${state.tool_turns}`);
802
+ state.current_round = (state.current_round ?? 0) + 1;
803
+ debug(`Tool call detected via msg_count (${savedMsgCount} \u2192 ${currentMsgCount}), tool_turns=${state.tool_turns}, round=${state.current_round}`);
750
804
  }
751
805
  }
752
806
  state.msg_count = currentMsgCount;
@@ -755,10 +809,15 @@ function processChunk(hookData) {
755
809
  state.accumulated_text += chunkText;
756
810
  state.last_tokens = completionTokens || state.last_tokens;
757
811
  if (thoughtsTokens > 0) state.thoughts_tokens = thoughtsTokens;
758
- }
759
- if (chunkText) {
812
+ const round = state.current_round ?? 0;
813
+ if (!state.text_rounds) state.text_rounds = [];
814
+ if (!state.round_start_times) state.round_start_times = [];
815
+ while (state.text_rounds.length <= round) state.text_rounds.push("");
816
+ while (state.round_start_times.length <= round) state.round_start_times.push("");
817
+ state.text_rounds[round] += chunkText;
818
+ if (!state.round_start_times[round]) state.round_start_times[round] = nowISO();
760
819
  saveStreamState(sessionId, state);
761
- debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}`);
820
+ debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}, round=${round}`);
762
821
  }
763
822
  const isToolTurn = hasToolCall || ["TOOL_CALLS", "FUNCTION_CALL", "TOOL_USE"].includes(finishReason);
764
823
  if (isToolTurn) {
@@ -798,7 +857,9 @@ function processChunk(hookData) {
798
857
  state.first_chunk_time || void 0,
799
858
  state.tool_turns ?? 0,
800
859
  state.tool_details ?? [],
801
- state.thoughts_tokens ?? 0
860
+ state.thoughts_tokens ?? 0,
861
+ state.text_rounds ?? [],
862
+ state.round_start_times ?? []
802
863
  );
803
864
  if (isFinished && chunkText) {
804
865
  debug(`Immediate send (text+STOP, tool_turns=${state.tool_turns ?? 0}), ${state.accumulated_text.length} chars`);
@@ -142,7 +142,7 @@ function detectModel(hookData) {
142
142
  return String(llmReq.model ?? '') || 'gemini-cli';
143
143
  }
144
144
  // ── Span construction ─────────────────────────────────────────────
145
- function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurns, toolDetails, thoughtsTokens) {
145
+ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurns, toolDetails, thoughtsTokens, textRounds, roundStartTimes) {
146
146
  const spans = [];
147
147
  const sessionId = String(hookData.session_id ?? '');
148
148
  const model = detectModel(hookData);
@@ -151,21 +151,17 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
151
151
  const beginTime = startTimeIso || endTime;
152
152
  const lat = latencySeconds(beginTime, endTime);
153
153
  const promptMessages = extractMessages(hookData);
154
- const completionMessage = { role: 'assistant', content: truncate(outputText, MAX_CHARS) };
155
154
  const { workflowName, spanName, customerId } = resolveSpanFields(config, {
156
155
  workflowName: 'gemini-cli',
157
156
  spanName: 'gemini-cli',
158
157
  });
159
158
  const safeId = sessionId.replace(/[/\\]/g, '_').slice(0, 50);
160
- // Use first chunk timestamp to differentiate turns within the same session
161
159
  const turnTs = beginTime.replace(/[^0-9]/g, '').slice(0, 14);
162
160
  const traceUniqueId = `gcli_${safeId}_${turnTs}`;
163
161
  const rootSpanId = `gcli_${safeId}_${turnTs}_root`;
164
162
  const threadId = `gcli_${sessionId}`;
165
- // LLM config
166
163
  const llmReq = (hookData.llm_request ?? {});
167
164
  const reqConfig = (llmReq.config ?? {});
168
- // Metadata
169
165
  const baseMeta = { source: 'gemini-cli' };
170
166
  if (toolTurns > 0)
171
167
  baseMeta.tool_turns = toolTurns;
@@ -190,55 +186,99 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
190
186
  metadata,
191
187
  ...(lat !== undefined ? { latency: lat } : {}),
192
188
  });
193
- // Generation child span
194
- const genSpan = {
195
- trace_unique_id: traceUniqueId,
196
- span_unique_id: `gcli_${safeId}_${turnTs}_gen`,
197
- span_parent_id: rootSpanId,
198
- span_name: 'gemini.chat',
199
- span_workflow_name: workflowName,
200
- span_path: 'gemini_chat',
201
- model,
202
- provider_id: 'google',
203
- metadata: {},
204
- input: promptMessages.length ? JSON.stringify(promptMessages) : '',
205
- output: truncate(outputText, MAX_CHARS),
206
- timestamp: endTime,
207
- start_time: beginTime,
208
- prompt_tokens: tokens.prompt_tokens,
209
- completion_tokens: tokens.completion_tokens,
210
- total_tokens: tokens.total_tokens,
211
- ...(lat !== undefined ? { latency: lat } : {}),
212
- };
213
- if (reqConfig.temperature != null)
214
- genSpan.temperature = reqConfig.temperature;
215
- if (reqConfig.maxOutputTokens != null)
216
- genSpan.max_tokens = reqConfig.maxOutputTokens;
217
- spans.push(genSpan);
218
- // Reasoning span
219
- if (thoughtsTokens > 0) {
220
- spans.push({
221
- trace_unique_id: traceUniqueId,
222
- span_unique_id: `gcli_${safeId}_${turnTs}_reasoning`,
223
- span_parent_id: rootSpanId,
224
- span_name: 'Reasoning',
225
- span_workflow_name: workflowName,
226
- span_path: 'reasoning',
227
- provider_id: '',
228
- metadata: { reasoning_tokens: thoughtsTokens },
229
- input: '',
230
- output: `[Reasoning: ${thoughtsTokens} tokens]`,
231
- timestamp: endTime,
232
- start_time: beginTime,
233
- });
189
+ // Build interleaved LLM + Tool spans in chronological order.
190
+ // If we have text rounds, create one gemini.chat per round with tools between them.
191
+ // Otherwise fall back to a single gemini.chat span.
192
+ const rounds = textRounds.length > 0 ? textRounds : [outputText];
193
+ const roundStarts = roundStartTimes.length > 0 ? roundStartTimes : [beginTime];
194
+ let toolIdx = 0;
195
+ for (let r = 0; r < rounds.length; r++) {
196
+ const roundText = rounds[r];
197
+ const roundStart = roundStarts[r] || beginTime;
198
+ // Round end: next tool start, or endTime for last round
199
+ const nextTool = toolIdx < toolDetails.length ? toolDetails[toolIdx] : null;
200
+ const roundEnd = (r < rounds.length - 1 && nextTool?.start_time) ? nextTool.start_time : endTime;
201
+ const roundLat = latencySeconds(roundStart, roundEnd);
202
+ // LLM generation span for this round
203
+ if (roundText) {
204
+ const genSpan = {
205
+ trace_unique_id: traceUniqueId,
206
+ span_unique_id: `gcli_${safeId}_${turnTs}_gen_${r}`,
207
+ span_parent_id: rootSpanId,
208
+ span_name: 'gemini.chat',
209
+ span_workflow_name: workflowName,
210
+ span_path: 'gemini_chat',
211
+ model,
212
+ provider_id: 'google',
213
+ metadata: {},
214
+ input: r === 0 && promptMessages.length ? JSON.stringify(promptMessages) : '',
215
+ output: truncate(roundText, MAX_CHARS),
216
+ timestamp: roundEnd,
217
+ start_time: roundStart,
218
+ ...(roundLat !== undefined ? { latency: roundLat } : {}),
219
+ // Only attach tokens to the first round (aggregate usage from Gemini)
220
+ ...(r === 0 ? {
221
+ prompt_tokens: tokens.prompt_tokens,
222
+ completion_tokens: tokens.completion_tokens,
223
+ total_tokens: tokens.total_tokens,
224
+ } : {}),
225
+ };
226
+ if (r === 0) {
227
+ if (reqConfig.temperature != null)
228
+ genSpan.temperature = reqConfig.temperature;
229
+ if (reqConfig.maxOutputTokens != null)
230
+ genSpan.max_tokens = reqConfig.maxOutputTokens;
231
+ }
232
+ spans.push(genSpan);
233
+ }
234
+ // Tool spans that come after this round (before next round)
235
+ if (r < rounds.length - 1) {
236
+ // Emit all tools between this round and the next
237
+ while (toolIdx < toolDetails.length) {
238
+ const detail = toolDetails[toolIdx];
239
+ const toolName = detail?.name ?? '';
240
+ const toolArgs = detail?.args ?? detail?.input ?? {};
241
+ const toolOutput = detail?.output ?? '';
242
+ const displayName = toolName ? toolDisplayName(toolName) : `Call ${toolIdx + 1}`;
243
+ const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : '';
244
+ const toolMeta = {};
245
+ if (toolName)
246
+ toolMeta.tool_name = toolName;
247
+ if (detail?.error)
248
+ toolMeta.error = detail.error;
249
+ const toolStart = detail?.start_time ?? beginTime;
250
+ const toolEnd = detail?.end_time ?? endTime;
251
+ const toolLat = latencySeconds(toolStart, toolEnd);
252
+ spans.push({
253
+ trace_unique_id: traceUniqueId,
254
+ span_unique_id: `gcli_${safeId}_${turnTs}_tool_${toolIdx + 1}`,
255
+ span_parent_id: rootSpanId,
256
+ span_name: `Tool: ${displayName}`,
257
+ span_workflow_name: workflowName,
258
+ span_path: toolName ? `tool_${toolName}` : 'tool_call',
259
+ provider_id: '',
260
+ metadata: toolMeta,
261
+ input: toolInputStr,
262
+ output: truncate(toolOutput, MAX_CHARS),
263
+ timestamp: toolEnd,
264
+ start_time: toolStart,
265
+ ...(toolLat !== undefined ? { latency: toolLat } : {}),
266
+ });
267
+ toolIdx++;
268
+ // If next tool starts after next round's start time, break — it belongs to a later gap
269
+ const nextDetail = toolDetails[toolIdx];
270
+ if (nextDetail && roundStarts[r + 1] && nextDetail.start_time && nextDetail.start_time > roundStarts[r + 1])
271
+ break;
272
+ }
273
+ }
234
274
  }
235
- // Tool child spans
236
- for (let i = 0; i < toolTurns; i++) {
237
- const detail = toolDetails[i] ?? null;
275
+ // Any remaining tools not yet emitted (e.g. only one round but tools exist)
276
+ while (toolIdx < toolDetails.length) {
277
+ const detail = toolDetails[toolIdx];
238
278
  const toolName = detail?.name ?? '';
239
279
  const toolArgs = detail?.args ?? detail?.input ?? {};
240
280
  const toolOutput = detail?.output ?? '';
241
- const displayName = toolName ? toolDisplayName(toolName) : `Call ${i + 1}`;
281
+ const displayName = toolName ? toolDisplayName(toolName) : `Call ${toolIdx + 1}`;
242
282
  const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : '';
243
283
  const toolMeta = {};
244
284
  if (toolName)
@@ -250,7 +290,7 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
250
290
  const toolLat = latencySeconds(toolStart, toolEnd);
251
291
  spans.push({
252
292
  trace_unique_id: traceUniqueId,
253
- span_unique_id: `gcli_${safeId}_${turnTs}_tool_${i + 1}`,
293
+ span_unique_id: `gcli_${safeId}_${turnTs}_tool_${toolIdx + 1}`,
254
294
  span_parent_id: rootSpanId,
255
295
  span_name: `Tool: ${displayName}`,
256
296
  span_workflow_name: workflowName,
@@ -263,6 +303,24 @@ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurn
263
303
  start_time: toolStart,
264
304
  ...(toolLat !== undefined ? { latency: toolLat } : {}),
265
305
  });
306
+ toolIdx++;
307
+ }
308
+ // Reasoning span
309
+ if (thoughtsTokens > 0) {
310
+ spans.push({
311
+ trace_unique_id: traceUniqueId,
312
+ span_unique_id: `gcli_${safeId}_${turnTs}_reasoning`,
313
+ span_parent_id: rootSpanId,
314
+ span_name: 'Reasoning',
315
+ span_workflow_name: workflowName,
316
+ span_path: 'reasoning',
317
+ provider_id: '',
318
+ metadata: { reasoning_tokens: thoughtsTokens },
319
+ input: '',
320
+ output: `[Reasoning: ${thoughtsTokens} tokens]`,
321
+ timestamp: endTime,
322
+ start_time: beginTime,
323
+ });
266
324
  }
267
325
  return addDefaultsToAll(spans);
268
326
  }
@@ -387,6 +445,10 @@ function processBeforeTool(hookData) {
387
445
  const pending = state.pending_tools ?? [];
388
446
  pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
389
447
  state.pending_tools = pending;
448
+ // Increment send_version to cancel any pending delayed sends —
449
+ // the turn isn't done yet, a tool is about to execute.
450
+ state.send_version = (state.send_version ?? 0) + 1;
451
+ state.tool_turns = (state.tool_turns ?? 0) + 1;
390
452
  saveStreamState(sessionId, state);
391
453
  }
392
454
  function processAfterTool(hookData) {
@@ -478,11 +540,13 @@ function processChunk(hookData) {
478
540
  state.tool_turns = (state.tool_turns ?? 0) + 1;
479
541
  state.send_version = (state.send_version ?? 0) + 1;
480
542
  toolCallDetected = true;
481
- debug(`Tool call detected via msg_count (${savedMsgCount} ${currentMsgCount}), tool_turns=${state.tool_turns}`);
543
+ // Start a new text round after tool completes
544
+ state.current_round = (state.current_round ?? 0) + 1;
545
+ debug(`Tool call detected via msg_count (${savedMsgCount} → ${currentMsgCount}), tool_turns=${state.tool_turns}, round=${state.current_round}`);
482
546
  }
483
547
  }
484
548
  state.msg_count = currentMsgCount;
485
- // Accumulate text and grounding tool details
549
+ // Accumulate text into both total and per-round tracking
486
550
  if (chunkText) {
487
551
  if (!state.first_chunk_time)
488
552
  state.first_chunk_time = nowISO();
@@ -490,10 +554,21 @@ function processChunk(hookData) {
490
554
  state.last_tokens = completionTokens || state.last_tokens;
491
555
  if (thoughtsTokens > 0)
492
556
  state.thoughts_tokens = thoughtsTokens;
493
- }
494
- if (chunkText) {
557
+ // Track text per round
558
+ const round = state.current_round ?? 0;
559
+ if (!state.text_rounds)
560
+ state.text_rounds = [];
561
+ if (!state.round_start_times)
562
+ state.round_start_times = [];
563
+ while (state.text_rounds.length <= round)
564
+ state.text_rounds.push('');
565
+ while (state.round_start_times.length <= round)
566
+ state.round_start_times.push('');
567
+ state.text_rounds[round] += chunkText;
568
+ if (!state.round_start_times[round])
569
+ state.round_start_times[round] = nowISO();
495
570
  saveStreamState(sessionId, state);
496
- debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}`);
571
+ debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}, round=${round}`);
497
572
  }
498
573
  // Tool call in response parts
499
574
  const isToolTurn = hasToolCall || ['TOOL_CALLS', 'FUNCTION_CALL', 'TOOL_USE'].includes(finishReason);
@@ -533,7 +608,7 @@ function processChunk(hookData) {
533
608
  const finalTotal = Number(usage.totalTokenCount ?? 0) || (finalPrompt + finalCompletion);
534
609
  const tok = { prompt_tokens: finalPrompt, completion_tokens: finalCompletion, total_tokens: finalTotal };
535
610
  const config = loadRespanConfig(path.join(os.homedir(), '.gemini', 'respan.json'));
536
- const spans = buildSpans(hookData, state.accumulated_text, tok, config, state.first_chunk_time || undefined, state.tool_turns ?? 0, state.tool_details ?? [], state.thoughts_tokens ?? 0);
611
+ const spans = buildSpans(hookData, state.accumulated_text, tok, config, state.first_chunk_time || undefined, state.tool_turns ?? 0, state.tool_details ?? [], state.thoughts_tokens ?? 0, state.text_rounds ?? [], state.round_start_times ?? []);
537
612
  // Method b: text + STOP → send immediately
538
613
  if (isFinished && chunkText) {
539
614
  debug(`Immediate send (text+STOP, tool_turns=${state.tool_turns ?? 0}), ${state.accumulated_text.length} chars`);