Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { localize } from '../../../../../nls.js';
7
import { IChatDebugEvent } from '../../common/chatDebugService.js';
8
9
// ---- Data model ----
10
11
export interface FlowNode {
12
readonly id: string;
13
readonly kind: IChatDebugEvent['kind'];
14
/** For `generic` nodes: the event category (e.g. `'discovery'`). Used to narrow filtering. */
15
readonly category?: string;
16
readonly label: string;
17
readonly sublabel?: string;
18
readonly description?: string;
19
readonly tooltip?: string;
20
readonly isError?: boolean;
21
readonly created: number;
22
readonly children: FlowNode[];
23
/** Present on merged discovery nodes: the individual nodes that were merged. */
24
readonly mergedNodes?: FlowNode[];
25
}
26
27
export interface FlowFilterOptions {
28
readonly isKindVisible: (kind: string, category?: string) => boolean;
29
readonly textFilter: string;
30
}
31
32
export interface LayoutNode {
33
readonly id: string;
34
readonly kind: IChatDebugEvent['kind'];
35
readonly label: string;
36
readonly sublabel?: string;
37
readonly tooltip?: string;
38
readonly isError?: boolean;
39
readonly x: number;
40
readonly y: number;
41
readonly width: number;
42
readonly height: number;
43
/** Number of individual nodes merged into this one (for discovery merging). */
44
readonly mergedCount?: number;
45
/** Whether the merged node is currently expanded (individual nodes shown to the right). */
46
readonly isMergedExpanded?: boolean;
47
}
48
49
export interface LayoutEdge {
50
readonly fromId?: string;
51
readonly toId?: string;
52
readonly fromX: number;
53
readonly fromY: number;
54
readonly toX: number;
55
readonly toY: number;
56
}
57
58
export interface SubgraphRect {
59
readonly label: string;
60
readonly x: number;
61
readonly y: number;
62
readonly width: number;
63
readonly height: number;
64
readonly depth: number;
65
readonly nodeId: string;
66
readonly collapsedChildCount?: number;
67
}
68
69
export interface FlowLayout {
70
readonly nodes: LayoutNode[];
71
readonly edges: LayoutEdge[];
72
readonly subgraphs: SubgraphRect[];
73
readonly width: number;
74
readonly height: number;
75
}
76
77
export interface FlowChartRenderResult {
78
readonly svg: SVGElement;
79
/** Map from node/subgraph ID to its focusable SVG element. */
80
readonly focusableElements: Map<string, SVGElement>;
81
/** Adjacency lists derived from graph edges: successors and predecessors per node ID. */
82
readonly adjacency: Map<string, { next: string[]; prev: string[] }>;
83
/** Map from node/subgraph ID to its layout position. */
84
readonly positions: Map<string, { x: number; y: number }>;
85
}
86
87
// ---- Build flow graph from debug events ----
88
89
/**
90
* Truncates a string to a max length, appending an ellipsis if trimmed.
91
*/
92
function truncateLabel(text: string, maxLength: number): string {
93
if (text.length <= maxLength) {
94
return text;
95
}
96
return text.substring(0, maxLength - 1) + '\u2026';
97
}
98
99
export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] {
100
// Before filtering, extract description metadata from subagent events
101
// that will be filtered out, so we can enrich the surviving sibling events.
102
const subagentToolNames = ['runSubagent', 'search_subagent'];
103
104
/**
105
* Check whether a name matches a known subagent tool name.
106
* Handles exact matches and names with suffixes (e.g.
107
* "runSubagent-default", "runSubagent (agent)", "runSubagent: desc").
108
* Callers must strip any emoji prefix before calling.
109
*/
110
function isSubagentName(name: string): boolean {
111
for (const toolName of subagentToolNames) {
112
if (name === toolName) {
113
return true;
114
}
115
if (name.startsWith(toolName)) {
116
const nextChar = name[toolName.length];
117
if (nextChar === '-' || nextChar === ' ' || nextChar === '(' || nextChar === ':') {
118
return true;
119
}
120
}
121
}
122
return false;
123
}
124
125
/** Strip the leading tool emoji prefix if present. */
126
const emojiPrefixRe = /^\u{1F6E0}\uFE0F?\s*/u;
127
function stripToolEmoji(name: string): string {
128
return name.replace(emojiPrefixRe, '');
129
}
130
131
// The extension may emit two subagentInvocation events per subagent:
132
// 1. "started" marker (agentName = descriptive name, status = running) — survives filtering
133
// 2. completion event (agentName = "runSubagent" / "runSubagent-*", status = completed) — filtered out
134
// The completion event carries the real description. When multiple subagents
135
// run under the same parent, they share a parentEventId, so we match them
136
// by order: the N-th started marker gets the N-th completion's description.
137
const completionDescsByParent = new Map<string, string[]>();
138
const startedCountByParent = new Map<string, number>();
139
for (const e of events) {
140
if (e.kind === 'subagentInvocation' && isSubagentName(e.agentName) && e.description && e.parentEventId) {
141
let descs = completionDescsByParent.get(e.parentEventId);
142
if (!descs) {
143
descs = [];
144
completionDescsByParent.set(e.parentEventId, descs);
145
}
146
descs.push(e.description);
147
}
148
}
149
150
function getSubagentDescription(event: IChatDebugEvent): string | undefined {
151
if (event.kind !== 'subagentInvocation' || !event.parentEventId) {
152
return undefined;
153
}
154
const descs = completionDescsByParent.get(event.parentEventId);
155
if (!descs || descs.length === 0) {
156
return event.description && event.description !== event.agentName ? event.description : undefined;
157
}
158
const idx = startedCountByParent.get(event.parentEventId) ?? 0;
159
startedCountByParent.set(event.parentEventId, idx + 1);
160
return descs[idx] ?? descs[0];
161
}
162
163
// Filter out subagent invocation completion duplicates (events whose
164
// agentName matches a known tool name). Subagent tool calls are kept
165
// in the tree for correct parent-child linkage; they are collapsed
166
// into their subagent child in a post-processing step.
167
const filtered = events.filter(e => {
168
if (e.kind === 'subagentInvocation' && isSubagentName(e.agentName)) {
169
return false;
170
}
171
return true;
172
});
173
174
const idToEvent = new Map<string, IChatDebugEvent>();
175
const idToChildren = new Map<string, IChatDebugEvent[]>();
176
const roots: IChatDebugEvent[] = [];
177
178
for (const event of filtered) {
179
if (event.id) {
180
idToEvent.set(event.id, event);
181
}
182
}
183
184
for (const event of filtered) {
185
if (event.parentEventId && idToEvent.has(event.parentEventId)) {
186
let children = idToChildren.get(event.parentEventId);
187
if (!children) {
188
children = [];
189
idToChildren.set(event.parentEventId, children);
190
}
191
children.push(event);
192
} else {
193
roots.push(event);
194
}
195
}
196
197
function toFlowNode(event: IChatDebugEvent): FlowNode {
198
const children = event.id ? idToChildren.get(event.id) : undefined;
199
200
// Remap generic events with well-known names to their proper kind
201
// so they get correct styling and sublabel treatment.
202
const effectiveKind = getEffectiveKind(event);
203
204
// For subagent invocations, enrich with description from the
205
// filtered-out completion sibling, or fall back to the event's own field.
206
let label = getEventLabel(event, effectiveKind);
207
const sublabel = getEventSublabel(event, effectiveKind);
208
let tooltip = getEventTooltip(event);
209
let description: string | undefined;
210
if (effectiveKind === 'subagentInvocation') {
211
description = getSubagentDescription(event);
212
// Strip any existing "Subagent:" prefix from the description to
213
// avoid double-prefixing (e.g. "Subagent: Subagent: name").
214
const cleanDesc = description?.replace(/^Subagent:\s*/i, '');
215
// Show "Subagent: <description>" as the label so users can identify
216
// these nodes and see what task they perform.
217
label = cleanDesc
218
? localize('subagentWithDesc', "Subagent: {0}", truncateLabel(cleanDesc, 30))
219
: localize('subagentLabel', "Subagent");
220
if (description) {
221
// Ensure description appears in tooltip if not already present
222
if (tooltip && !tooltip.includes(description)) {
223
const lines = tooltip.split('\n');
224
lines.splice(1, 0, description);
225
tooltip = lines.join('\n');
226
}
227
}
228
}
229
230
return {
231
id: event.id ?? `event-${events.indexOf(event)}`,
232
kind: effectiveKind,
233
category: event.kind === 'generic' ? event.category : undefined,
234
label,
235
sublabel,
236
description,
237
tooltip,
238
isError: isErrorEvent(event),
239
created: event.created.getTime(),
240
children: children?.map(toFlowNode) ?? [],
241
};
242
}
243
244
const rawNodes = roots.map(toFlowNode);
245
246
// Post-process: collapse subagent tool call nodes into their
247
// subagent child, and flatten child_session_ref placeholder nodes.
248
// This preserves the correct parent-child hierarchy that would
249
// otherwise break when filtering events before tree construction.
250
return collapseSubagentToolCalls(rawNodes);
251
252
function collapseSubagentToolCalls(nodeList: FlowNode[]): FlowNode[] {
253
let changed = false;
254
const result: FlowNode[] = [];
255
for (const node of nodeList) {
256
if (node.kind === 'toolCall' && isSubagentName(stripToolEmoji(node.label))) {
257
changed = true;
258
// Flatten any child_session_ref intermediaries first so
259
// the subagentInvocation becomes a direct child.
260
const flatChildren = flattenChildSessionRefs(node.children);
261
const subagentChildren = flatChildren.filter(c => c.kind === 'subagentInvocation');
262
if (subagentChildren.length > 0) {
263
const otherChildren = flatChildren.filter(c => c.kind !== 'subagentInvocation');
264
// Each subagent child gets its own children; non-subagent
265
// siblings (which are rare) are added to the first subagent.
266
for (let i = 0; i < subagentChildren.length; i++) {
267
const extra = i === 0 ? otherChildren : [];
268
result.push({
269
...subagentChildren[i],
270
children: collapseSubagentToolCalls(
271
[...subagentChildren[i].children, ...extra]
272
),
273
});
274
}
275
} else {
276
// No subagent child — promote children directly
277
result.push(...collapseSubagentToolCalls(flatChildren));
278
}
279
} else {
280
const newChildren = collapseSubagentToolCalls(node.children);
281
if (newChildren !== node.children) {
282
changed = true;
283
result.push({ ...node, children: newChildren });
284
} else {
285
result.push(node);
286
}
287
}
288
}
289
return changed ? result : nodeList;
290
}
291
292
function flattenChildSessionRefs(nodeList: FlowNode[]): FlowNode[] {
293
if (!nodeList.some(n => n.kind === 'generic' && n.category === 'subagent')) {
294
return nodeList; // fast path: nothing to flatten
295
}
296
const result: FlowNode[] = [];
297
for (const node of nodeList) {
298
if (node.kind === 'generic' && node.category === 'subagent') {
299
// child_session_ref placeholder — find the subagentInvocation
300
// and move all siblings into it as children.
301
const subagentChild = node.children.find(c => c.kind === 'subagentInvocation');
302
if (subagentChild) {
303
const siblings = node.children.filter(c => c !== subagentChild);
304
result.push({
305
...subagentChild,
306
children: [...subagentChild.children, ...siblings],
307
});
308
} else {
309
// No subagent child — promote all children
310
result.push(...node.children);
311
}
312
} else {
313
result.push(node);
314
}
315
}
316
return result;
317
}
318
}
319
320
// ---- Flow node filtering ----
321
322
/**
323
* Filters a flow node tree by kind visibility and text search.
324
* Returns a new tree — the input is not mutated.
325
*
326
* Kind filtering: nodes whose kind is not visible are removed.
327
* For `subagentInvocation` nodes, the entire subgraph is removed.
328
* For other kinds, the node is removed and its children are re-parented.
329
*
330
* Text filtering: only nodes whose label, sublabel, or tooltip match the
331
* search term are kept, along with all their ancestors (path to root).
332
* If a subagent label matches, its entire subgraph is kept.
333
*/
334
export function filterFlowNodes(nodes: FlowNode[], options: FlowFilterOptions): FlowNode[] {
335
let result = filterByKind(nodes, options.isKindVisible);
336
if (options.textFilter) {
337
result = filterByText(result, options.textFilter);
338
}
339
return result;
340
}
341
342
function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string, category?: string) => boolean): FlowNode[] {
343
const result: FlowNode[] = [];
344
let changed = false;
345
for (const node of nodes) {
346
if (!isKindVisible(node.kind, node.category)) {
347
changed = true;
348
// For subagents, drop the entire subgraph
349
if (node.kind === 'subagentInvocation') {
350
continue;
351
}
352
// For other kinds, re-parent children up
353
result.push(...filterByKind(node.children, isKindVisible));
354
continue;
355
}
356
const filteredChildren = filterByKind(node.children, isKindVisible);
357
if (filteredChildren !== node.children) {
358
changed = true;
359
result.push({ ...node, children: filteredChildren });
360
} else {
361
result.push(node);
362
}
363
}
364
return changed ? result : nodes;
365
}
366
367
368
function nodeMatchesText(node: FlowNode, text: string): boolean {
369
return node.label.toLowerCase().includes(text) ||
370
(node.sublabel?.toLowerCase().includes(text) ?? false) ||
371
(node.tooltip?.toLowerCase().includes(text) ?? false);
372
}
373
374
function filterByText(nodes: FlowNode[], text: string): FlowNode[] {
375
const result: FlowNode[] = [];
376
for (const node of nodes) {
377
if (nodeMatchesText(node, text)) {
378
// Node matches — keep it with all descendants
379
result.push(node);
380
continue;
381
}
382
// Check if any descendant matches
383
const filteredChildren = filterByText(node.children, text);
384
if (filteredChildren.length > 0) {
385
// Keep this node as an ancestor of matching descendants
386
result.push({ ...node, children: filteredChildren });
387
}
388
}
389
return result;
390
}
391
392
// ---- Node slicing (pagination) ----
393
394
export interface FlowSliceResult {
395
readonly nodes: FlowNode[];
396
readonly totalCount: number;
397
readonly shownCount: number;
398
}
399
400
/**
401
* Counts the total number of nodes in a tree (each node + all descendants).
402
*/
403
function countNodes(nodes: readonly FlowNode[]): number {
404
let count = 0;
405
for (const node of nodes) {
406
count += 1 + countNodes(node.children);
407
}
408
return count;
409
}
410
411
/**
412
* Slices a flow node tree to at most `maxCount` nodes (pre-order DFS).
413
*
414
* When a subagent's children would exceed the remaining budget, the
415
* children list is truncated. Returns the sliced tree along with total
416
* and shown node counts for the "Show More" UI.
417
*/
418
export function sliceFlowNodes(nodes: readonly FlowNode[], maxCount: number): FlowSliceResult {
419
const totalCount = countNodes(nodes);
420
if (totalCount <= maxCount) {
421
return { nodes: nodes as FlowNode[], totalCount, shownCount: totalCount };
422
}
423
424
let remaining = maxCount;
425
426
function sliceTree(nodeList: readonly FlowNode[]): FlowNode[] {
427
const result: FlowNode[] = [];
428
for (const node of nodeList) {
429
if (remaining <= 0) {
430
break;
431
}
432
remaining--; // count this node
433
if (node.children.length === 0 || remaining <= 0) {
434
result.push(node.children.length === 0 ? node : { ...node, children: [] });
435
} else {
436
const slicedChildren = sliceTree(node.children);
437
result.push(slicedChildren !== node.children ? { ...node, children: slicedChildren } : node);
438
}
439
}
440
return result;
441
}
442
443
const sliced = sliceTree(nodes);
444
const shownCount = maxCount - remaining;
445
return { nodes: sliced, totalCount, shownCount };
446
}
447
448
// ---- Discovery node merging ----
449
450
function isDiscoveryNode(node: FlowNode): boolean {
451
return node.kind === 'generic' && node.category === 'discovery';
452
}
453
454
/**
455
* Merges consecutive prompt-discovery nodes (generic events with
456
* `category === 'discovery'`) into a single summary node.
457
*
458
* The merged node always stays in the graph and carries the individual
459
* nodes in `mergedNodes`. Expansion (showing the individual nodes to the
460
* right) is handled at the layout level.
461
*
462
* Operates recursively on children.
463
*/
464
export function mergeDiscoveryNodes(
465
nodes: readonly FlowNode[],
466
): FlowNode[] {
467
const result: FlowNode[] = [];
468
469
let i = 0;
470
while (i < nodes.length) {
471
const node = nodes[i];
472
473
// Non-discovery node: recurse into children and pass through.
474
if (!isDiscoveryNode(node)) {
475
const mergedChildren = mergeDiscoveryNodes(node.children);
476
result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);
477
i++;
478
continue;
479
}
480
481
// Accumulate a run of consecutive discovery nodes.
482
const run: FlowNode[] = [node];
483
let j = i + 1;
484
while (j < nodes.length && isDiscoveryNode(nodes[j])) {
485
run.push(nodes[j]);
486
j++;
487
}
488
489
if (run.length < 2) {
490
// Single discovery node — nothing to merge.
491
result.push(node);
492
i = j;
493
continue;
494
}
495
496
// Build a stable id from the first node so the expand state persists.
497
const mergedId = `merged-discovery:${run[0].id}`;
498
499
// Build a merged summary node.
500
const labels = run.map(n => n.label);
501
const uniqueLabels = [...new Set(labels)];
502
const summaryLabel = uniqueLabels.length <= 2
503
? uniqueLabels.join(', ')
504
: localize('discoveryMergedLabel', "{0} +{1} more", uniqueLabels[0], run.length - 1);
505
506
result.push({
507
id: mergedId,
508
kind: 'generic',
509
category: 'discovery',
510
label: summaryLabel,
511
sublabel: localize('discoveryStepsCount', "{0} discovery steps", run.length),
512
tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'),
513
created: run[0].created,
514
children: [],
515
mergedNodes: run,
516
});
517
i = j;
518
}
519
520
return result;
521
}
522
523
// ---- Tool call node merging ----
524
525
function isToolCallNode(node: FlowNode): boolean {
526
return node.kind === 'toolCall';
527
}
528
529
/**
530
* Returns the tool name from a tool-call node's label.
531
* Tool call labels are set to `event.toolName` (possibly with a leading
532
* emoji prefix stripped), so the label itself is the canonical tool name.
533
*/
534
function getToolName(node: FlowNode): string {
535
return node.label;
536
}
537
538
/**
539
* Merges consecutive tool-call nodes that invoke the same tool into a
540
* single summary node.
541
*
542
* This mirrors `mergeDiscoveryNodes`: the merged node carries the
543
* individual nodes in `mergedNodes` and expansion is handled at the
544
* layout level.
545
*
546
* Operates recursively on children.
547
*/
548
export function mergeToolCallNodes(
549
nodes: readonly FlowNode[],
550
): FlowNode[] {
551
const result: FlowNode[] = [];
552
553
let i = 0;
554
while (i < nodes.length) {
555
const node = nodes[i];
556
557
// Non-tool-call node: recurse into children and pass through.
558
if (!isToolCallNode(node)) {
559
const mergedChildren = mergeToolCallNodes(node.children);
560
result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);
561
i++;
562
continue;
563
}
564
565
// Accumulate a run of consecutive tool-call nodes with the same tool name.
566
const toolName = getToolName(node);
567
const run: FlowNode[] = [node];
568
let j = i + 1;
569
while (j < nodes.length && isToolCallNode(nodes[j]) && getToolName(nodes[j]) === toolName) {
570
run.push(nodes[j]);
571
j++;
572
}
573
574
if (run.length < 2) {
575
// Single tool call — recurse into children, nothing to merge.
576
const mergedChildren = mergeToolCallNodes(node.children);
577
result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);
578
i = j;
579
continue;
580
}
581
582
// Build a stable id from the first node so the expand state persists.
583
const mergedId = `merged-toolCall:${run[0].id}`;
584
585
result.push({
586
id: mergedId,
587
kind: 'toolCall',
588
label: toolName,
589
sublabel: localize('toolCallsCount', "{0} calls", run.length),
590
tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'),
591
created: run[0].created,
592
children: [],
593
mergedNodes: run,
594
});
595
i = j;
596
}
597
598
return result;
599
}
600
601
// ---- Event helpers ----
602
603
/**
604
* Remaps generic events with well-known names (e.g. "User message",
605
* "Agent response") to their proper typed kind so they receive
606
* correct colors, labels, and sublabel treatment in the flow chart.
607
*/
608
function getEffectiveKind(event: IChatDebugEvent): IChatDebugEvent['kind'] {
609
if (event.kind === 'generic') {
610
const name = event.name.toLowerCase().replace(/[\s_-]+/g, '');
611
if (name === 'usermessage' || name === 'userprompt' || name === 'user' || name.startsWith('usermessage')) {
612
return 'userMessage';
613
}
614
if (name === 'response' || name.startsWith('agentresponse') || name.startsWith('assistantresponse') || name.startsWith('modelresponse')) {
615
return 'agentResponse';
616
}
617
const cat = event.category?.toLowerCase();
618
if (cat === 'user' || cat === 'usermessage') {
619
return 'userMessage';
620
}
621
if (cat === 'response' || cat === 'agentresponse') {
622
return 'agentResponse';
623
}
624
}
625
return event.kind;
626
}
627
628
function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string {
629
const kind = effectiveKind ?? event.kind;
630
switch (kind) {
631
case 'userMessage':
632
return localize('userLabel', "User Message");
633
case 'modelTurn':
634
return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn");
635
case 'toolCall':
636
return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : localize('toolCallLabel', "Tool Call");
637
case 'subagentInvocation':
638
return event.kind === 'subagentInvocation' ? event.agentName : localize('subagentFallback', "Subagent");
639
case 'agentResponse':
640
return localize('agentResponseLabel', "Agent Response");
641
case 'generic':
642
return event.kind === 'generic' ? event.name : localize('genericLabel', "Event");
643
}
644
}
645
646
function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string | undefined {
647
const kind = effectiveKind ?? event.kind;
648
switch (kind) {
649
case 'modelTurn': {
650
const parts: string[] = [];
651
if (event.kind === 'modelTurn' && event.requestName) {
652
parts.push(event.requestName);
653
}
654
if (event.kind === 'modelTurn' && event.totalTokens) {
655
parts.push(localize('tokenCount', "{0} tokens", event.totalTokens));
656
}
657
if (event.kind === 'modelTurn' && event.durationInMillis) {
658
parts.push(formatDuration(event.durationInMillis));
659
}
660
return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;
661
}
662
case 'toolCall': {
663
const parts: string[] = [];
664
if (event.kind === 'toolCall' && event.result) {
665
parts.push(event.result);
666
}
667
if (event.kind === 'toolCall' && event.durationInMillis) {
668
parts.push(formatDuration(event.durationInMillis));
669
}
670
return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;
671
}
672
case 'subagentInvocation': {
673
const parts: string[] = [];
674
if (event.kind === 'subagentInvocation' && event.status) {
675
parts.push(event.status);
676
}
677
if (event.kind === 'subagentInvocation' && event.durationInMillis) {
678
parts.push(formatDuration(event.durationInMillis));
679
}
680
return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;
681
}
682
case 'userMessage':
683
case 'agentResponse': {
684
// Use the message summary as the sublabel. For remapped generic
685
// events, use the details property.
686
let text: string | undefined;
687
if (event.kind === 'userMessage' || event.kind === 'agentResponse') {
688
text = event.message;
689
} else if (event.kind === 'generic') {
690
text = event.details;
691
}
692
if (!text) {
693
return undefined;
694
}
695
// Find the first meaningful line, skipping trivial lines like
696
// lone brackets/braces that appear when the message is JSON.
697
const lines = text.split('\n');
698
let firstLine = '';
699
for (const line of lines) {
700
const trimmed = line.trim();
701
if (trimmed && trimmed.length > 2) {
702
firstLine = trimmed;
703
break;
704
}
705
}
706
if (!firstLine) {
707
// Fall back to the full text collapsed to a single line
708
firstLine = text.replace(/\s+/g, ' ').trim();
709
}
710
if (!firstLine) {
711
return undefined;
712
}
713
return firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine;
714
}
715
default:
716
return undefined;
717
}
718
}
719
720
function formatDuration(ms: number): string {
721
if (ms < 1000) {
722
return `${ms}ms`;
723
}
724
return `${(ms / 1000).toFixed(1)}s`;
725
}
726
727
function isErrorEvent(event: IChatDebugEvent): boolean {
728
return (event.kind === 'toolCall' && event.result === 'error') ||
729
(event.kind === 'generic' && event.level === 3 /* ChatDebugLogLevel.Error */) ||
730
(event.kind === 'subagentInvocation' && event.status === 'failed');
731
}
732
733
const TOOLTIP_MAX_LENGTH = 500;
734
735
function getEventTooltip(event: IChatDebugEvent): string | undefined {
736
switch (event.kind) {
737
case 'userMessage': {
738
const msg = event.message.trim();
739
if (msg.length > TOOLTIP_MAX_LENGTH) {
740
return msg.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026';
741
}
742
return msg || undefined;
743
}
744
case 'toolCall': {
745
const parts: string[] = [event.toolName];
746
if (event.input) {
747
const input = event.input.trim();
748
parts.push(localize('tooltipInput', "Input: {0}", input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input));
749
}
750
if (event.output) {
751
const output = event.output.trim();
752
parts.push(localize('tooltipOutput', "Output: {0}", output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output));
753
}
754
if (event.result) {
755
parts.push(localize('tooltipResult', "Result: {0}", event.result));
756
}
757
return parts.join('\n');
758
}
759
case 'subagentInvocation': {
760
const parts: string[] = [event.agentName];
761
if (event.description) {
762
parts.push(event.description);
763
}
764
if (event.status) {
765
parts.push(localize('tooltipStatus', "Status: {0}", event.status));
766
}
767
if (event.toolCallCount !== undefined) {
768
parts.push(localize('tooltipToolCalls', "Tool calls: {0}", event.toolCallCount));
769
}
770
if (event.modelTurnCount !== undefined) {
771
parts.push(localize('tooltipModelTurns', "Model turns: {0}", event.modelTurnCount));
772
}
773
return parts.join('\n');
774
}
775
case 'generic': {
776
if (event.details) {
777
const details = event.details.trim();
778
return details.length > TOOLTIP_MAX_LENGTH ? details.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : details;
779
}
780
return undefined;
781
}
782
case 'modelTurn': {
783
const parts: string[] = [];
784
if (event.model) {
785
parts.push(event.model);
786
}
787
if (event.totalTokens) {
788
parts.push(localize('tooltipTokens', "Tokens: {0}", event.totalTokens));
789
}
790
if (event.inputTokens) {
791
parts.push(localize('tooltipInputTokens', "Input tokens: {0}", event.inputTokens));
792
}
793
if (event.outputTokens) {
794
parts.push(localize('tooltipOutputTokens', "Output tokens: {0}", event.outputTokens));
795
}
796
if (event.cachedTokens !== undefined) {
797
parts.push(localize('tooltipCachedTokens', "Cached tokens: {0}", event.cachedTokens));
798
}
799
if (event.durationInMillis) {
800
parts.push(localize('tooltipDuration', "Duration: {0}", formatDuration(event.durationInMillis)));
801
}
802
return parts.length > 0 ? parts.join('\n') : undefined;
803
}
804
default:
805
return undefined;
806
}
807
}
808
809