@respan/cli 0.6.8 → 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
  }
@@ -748,7 +799,8 @@ function processChunk(hookData) {
748
799
  state.tool_turns = (state.tool_turns ?? 0) + 1;
749
800
  state.send_version = (state.send_version ?? 0) + 1;
750
801
  toolCallDetected = true;
751
- 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}`);
752
804
  }
753
805
  }
754
806
  state.msg_count = currentMsgCount;
@@ -757,10 +809,15 @@ function processChunk(hookData) {
757
809
  state.accumulated_text += chunkText;
758
810
  state.last_tokens = completionTokens || state.last_tokens;
759
811
  if (thoughtsTokens > 0) state.thoughts_tokens = thoughtsTokens;
760
- }
761
- 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();
762
819
  saveStreamState(sessionId, state);
763
- 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}`);
764
821
  }
765
822
  const isToolTurn = hasToolCall || ["TOOL_CALLS", "FUNCTION_CALL", "TOOL_USE"].includes(finishReason);
766
823
  if (isToolTurn) {
@@ -800,7 +857,9 @@ function processChunk(hookData) {
800
857
  state.first_chunk_time || void 0,
801
858
  state.tool_turns ?? 0,
802
859
  state.tool_details ?? [],
803
- state.thoughts_tokens ?? 0
860
+ state.thoughts_tokens ?? 0,
861
+ state.text_rounds ?? [],
862
+ state.round_start_times ?? []
804
863
  );
805
864
  if (isFinished && chunkText) {
806
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
  }
@@ -482,11 +540,13 @@ function processChunk(hookData) {
482
540
  state.tool_turns = (state.tool_turns ?? 0) + 1;
483
541
  state.send_version = (state.send_version ?? 0) + 1;
484
542
  toolCallDetected = true;
485
- 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}`);
486
546
  }
487
547
  }
488
548
  state.msg_count = currentMsgCount;
489
- // Accumulate text and grounding tool details
549
+ // Accumulate text into both total and per-round tracking
490
550
  if (chunkText) {
491
551
  if (!state.first_chunk_time)
492
552
  state.first_chunk_time = nowISO();
@@ -494,10 +554,21 @@ function processChunk(hookData) {
494
554
  state.last_tokens = completionTokens || state.last_tokens;
495
555
  if (thoughtsTokens > 0)
496
556
  state.thoughts_tokens = thoughtsTokens;
497
- }
498
- 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();
499
570
  saveStreamState(sessionId, state);
500
- 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}`);
501
572
  }
502
573
  // Tool call in response parts
503
574
  const isToolTurn = hasToolCall || ['TOOL_CALLS', 'FUNCTION_CALL', 'TOOL_USE'].includes(finishReason);
@@ -537,7 +608,7 @@ function processChunk(hookData) {
537
608
  const finalTotal = Number(usage.totalTokenCount ?? 0) || (finalPrompt + finalCompletion);
538
609
  const tok = { prompt_tokens: finalPrompt, completion_tokens: finalCompletion, total_tokens: finalTotal };
539
610
  const config = loadRespanConfig(path.join(os.homedir(), '.gemini', 'respan.json'));
540
- 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 ?? []);
541
612
  // Method b: text + STOP → send immediately
542
613
  if (isFinished && chunkText) {
543
614
  debug(`Immediate send (text+STOP, tool_turns=${state.tool_turns ?? 0}), ${state.accumulated_text.length} chars`);