copilot-cli-trace-deck 0.1.0__py3-none-any.whl
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.
- copilot_cli_trace_deck/__init__.py +1 -0
- copilot_cli_trace_deck/__main__.py +5 -0
- copilot_cli_trace_deck/data/__init__.py +5 -0
- copilot_cli_trace_deck/data/sessions.py +628 -0
- copilot_cli_trace_deck/models.py +61 -0
- copilot_cli_trace_deck/server.py +3 -0
- copilot_cli_trace_deck/web/__init__.py +1 -0
- copilot_cli_trace_deck/web/pages.py +2305 -0
- copilot_cli_trace_deck/web/server.py +315 -0
- copilot_cli_trace_deck-0.1.0.dist-info/METADATA +63 -0
- copilot_cli_trace_deck-0.1.0.dist-info/RECORD +15 -0
- copilot_cli_trace_deck-0.1.0.dist-info/WHEEL +5 -0
- copilot_cli_trace_deck-0.1.0.dist-info/entry_points.txt +2 -0
- copilot_cli_trace_deck-0.1.0.dist-info/licenses/LICENSE +21 -0
- copilot_cli_trace_deck-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Copilot CLI Trace Deck package."""
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_session_previews(session_root: Path) -> list[SessionPreview]:
|
|
11
|
+
session_rows: list[tuple[str, SessionPreview]] = []
|
|
12
|
+
if not session_root.exists():
|
|
13
|
+
return []
|
|
14
|
+
|
|
15
|
+
for session_dir in session_root.iterdir():
|
|
16
|
+
if not session_dir.is_dir():
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
metadata = read_workspace_metadata(session_dir / "workspace.yaml")
|
|
20
|
+
title = session_title_from_metadata(metadata)
|
|
21
|
+
if not title:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
updated_at = metadata.get("updated_at") or metadata.get("created_at") or ""
|
|
25
|
+
session_rows.append(
|
|
26
|
+
(
|
|
27
|
+
updated_at,
|
|
28
|
+
SessionPreview(session_id=session_dir.name, title=title),
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
session_rows.sort(key=lambda item: item[0], reverse=True)
|
|
33
|
+
previews = [session for _, session in session_rows]
|
|
34
|
+
if previews:
|
|
35
|
+
active = previews[0]
|
|
36
|
+
previews[0] = SessionPreview(session_id=active.session_id, title=active.title, is_active=True)
|
|
37
|
+
return previews
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_session_summary(session_root: Path, session_id: str) -> SessionSummary | None:
|
|
41
|
+
session_dir = session_root / session_id
|
|
42
|
+
if not session_dir.is_dir():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
metadata = read_workspace_metadata(session_dir / "workspace.yaml")
|
|
46
|
+
title = session_title_from_metadata(metadata)
|
|
47
|
+
if not title:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
51
|
+
shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
|
|
52
|
+
model_name = find_current_model(events, shutdown_event)
|
|
53
|
+
usage = extract_usage(shutdown_event, model_name)
|
|
54
|
+
created_value = metadata.get("created_at") or first_event_timestamp(events)
|
|
55
|
+
updated_value = metadata.get("updated_at") or last_event_timestamp(events) or created_value
|
|
56
|
+
|
|
57
|
+
return SessionSummary(
|
|
58
|
+
session_id=session_id,
|
|
59
|
+
title=title,
|
|
60
|
+
created_label=format_timestamp(created_value),
|
|
61
|
+
updated_label=format_timestamp(updated_value),
|
|
62
|
+
session_type="Local",
|
|
63
|
+
location="CLI",
|
|
64
|
+
status="Idle" if shutdown_event else "Active",
|
|
65
|
+
model_name=model_name or "Unknown",
|
|
66
|
+
repository=repository_name(metadata.get("repository", "")),
|
|
67
|
+
branch=metadata.get("branch", ""),
|
|
68
|
+
model_turns=count_events(events, "assistant.turn_start"),
|
|
69
|
+
tool_calls=count_events(events, "tool.execution_start"),
|
|
70
|
+
total_input_tokens=usage.get("inputTokens", 0),
|
|
71
|
+
total_output_tokens=usage.get("outputTokens", 0),
|
|
72
|
+
total_cached_input_tokens=usage.get("cacheReadTokens", 0),
|
|
73
|
+
total_tokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0),
|
|
74
|
+
error_count=count_errors(events),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_session_logs(session_root: Path, session_id: str) -> list[SessionLogEntry] | None:
|
|
79
|
+
session_dir = session_root / session_id
|
|
80
|
+
if not session_dir.is_dir():
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
84
|
+
return [build_log_entry(index, event) for index, event in enumerate(events)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_session_flow(session_root: Path, session_id: str) -> list[SessionFlowNode] | None:
|
|
88
|
+
session_dir = session_root / session_id
|
|
89
|
+
if not session_dir.is_dir():
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
93
|
+
shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
|
|
94
|
+
model_name = find_current_model(events, shutdown_event) or "Unknown"
|
|
95
|
+
return build_flow_nodes(events, model_name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_workspace_metadata(workspace_file: Path) -> dict[str, str]:
|
|
99
|
+
if not workspace_file.exists():
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
metadata: dict[str, str] = {}
|
|
103
|
+
for raw_line in workspace_file.read_text(encoding="utf-8").splitlines():
|
|
104
|
+
key, separator, value = raw_line.partition(":")
|
|
105
|
+
if not separator:
|
|
106
|
+
continue
|
|
107
|
+
metadata[key.strip()] = value.strip()
|
|
108
|
+
return metadata
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def session_title_from_metadata(metadata: dict[str, str]) -> str | None:
|
|
112
|
+
title = metadata.get("name", "").strip()
|
|
113
|
+
return title or None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_jsonl_events(events_file: Path) -> list[dict]:
|
|
117
|
+
if not events_file.exists():
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
events: list[dict] = []
|
|
121
|
+
for raw_line in events_file.read_text(encoding="utf-8").splitlines():
|
|
122
|
+
if not raw_line.strip():
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
events.append(json.loads(raw_line))
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
continue
|
|
128
|
+
return events
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_current_model(events: list[dict], shutdown_event: dict | None) -> str:
|
|
132
|
+
if shutdown_event:
|
|
133
|
+
model_name = shutdown_event.get("data", {}).get("currentModel")
|
|
134
|
+
if isinstance(model_name, str) and model_name:
|
|
135
|
+
return model_name
|
|
136
|
+
|
|
137
|
+
for event in reversed(events):
|
|
138
|
+
if event.get("type") != "session.model_change":
|
|
139
|
+
continue
|
|
140
|
+
model_name = event.get("data", {}).get("newModel")
|
|
141
|
+
if isinstance(model_name, str) and model_name:
|
|
142
|
+
return model_name
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def extract_usage(shutdown_event: dict | None, current_model: str) -> dict[str, int]:
|
|
147
|
+
if not shutdown_event:
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
model_metrics = shutdown_event.get("data", {}).get("modelMetrics", {})
|
|
151
|
+
if not isinstance(model_metrics, dict) or not model_metrics:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
if current_model and current_model in model_metrics:
|
|
155
|
+
usage = model_metrics[current_model].get("usage", {})
|
|
156
|
+
else:
|
|
157
|
+
first_metrics = next(iter(model_metrics.values()))
|
|
158
|
+
usage = first_metrics.get("usage", {}) if isinstance(first_metrics, dict) else {}
|
|
159
|
+
|
|
160
|
+
if not isinstance(usage, dict):
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"inputTokens": int(usage.get("inputTokens", 0) or 0),
|
|
165
|
+
"outputTokens": int(usage.get("outputTokens", 0) or 0),
|
|
166
|
+
"cacheReadTokens": int(usage.get("cacheReadTokens", 0) or 0),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def count_events(events: list[dict], event_type: str) -> int:
|
|
171
|
+
return sum(1 for event in events if event.get("type") == event_type)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def count_errors(events: list[dict]) -> int:
|
|
175
|
+
return sum(1 for event in events if is_error_event(event))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def first_event_timestamp(events: list[dict]) -> str:
|
|
179
|
+
for event in events:
|
|
180
|
+
timestamp = event.get("timestamp")
|
|
181
|
+
if isinstance(timestamp, str) and timestamp:
|
|
182
|
+
return timestamp
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def last_event_timestamp(events: list[dict]) -> str:
|
|
187
|
+
for event in reversed(events):
|
|
188
|
+
timestamp = event.get("timestamp")
|
|
189
|
+
if isinstance(timestamp, str) and timestamp:
|
|
190
|
+
return timestamp
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_timestamp(raw_value: str) -> str:
|
|
195
|
+
if not raw_value:
|
|
196
|
+
return "-"
|
|
197
|
+
try:
|
|
198
|
+
dt = datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone()
|
|
199
|
+
except ValueError:
|
|
200
|
+
return raw_value
|
|
201
|
+
|
|
202
|
+
hour = dt.hour % 12 or 12
|
|
203
|
+
ampm = "AM" if dt.hour < 12 else "PM"
|
|
204
|
+
return f"{dt.month}/{dt.day}/{dt.year}, {hour}:{dt.minute:02d}:{dt.second:02d} {ampm}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def repository_name(repository: str) -> str:
|
|
208
|
+
return repository.rsplit("/", 1)[-1] if repository else ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_log_entry(index: int, event: dict) -> SessionLogEntry:
|
|
212
|
+
event_type = str(event.get("type") or "event")
|
|
213
|
+
data = event.get("data", {})
|
|
214
|
+
return SessionLogEntry(
|
|
215
|
+
index=index,
|
|
216
|
+
created_label=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
217
|
+
name=display_event_name(event_type, data),
|
|
218
|
+
event_type=event_type,
|
|
219
|
+
details=display_event_details(event_type, data),
|
|
220
|
+
is_error=is_error_event(event),
|
|
221
|
+
sections=build_log_sections(event_type, event),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_error_event(event: dict) -> bool:
|
|
226
|
+
if event.get("type") == "abort":
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
data = event.get("data", {})
|
|
230
|
+
return isinstance(data, dict) and data.get("success") is False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def format_log_timestamp(raw_value: str) -> str:
|
|
234
|
+
if not raw_value:
|
|
235
|
+
return "-"
|
|
236
|
+
try:
|
|
237
|
+
dt = datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone()
|
|
238
|
+
except ValueError:
|
|
239
|
+
return raw_value
|
|
240
|
+
|
|
241
|
+
hour = dt.hour % 12 or 12
|
|
242
|
+
ampm = "AM" if dt.hour < 12 else "PM"
|
|
243
|
+
return f"{dt.strftime('%b')} {dt.day}, {hour}:{dt.minute:02d}:{dt.second:02d} {ampm}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def display_event_name(event_type: str, data: object) -> str:
|
|
247
|
+
if isinstance(data, dict):
|
|
248
|
+
tool_name = data.get("toolName")
|
|
249
|
+
if event_type.startswith("tool.execution") and isinstance(tool_name, str) and tool_name:
|
|
250
|
+
return tool_name
|
|
251
|
+
|
|
252
|
+
if event_type.endswith(".message"):
|
|
253
|
+
role = data.get("role")
|
|
254
|
+
if isinstance(role, str) and role:
|
|
255
|
+
return f"{role.title()} Message"
|
|
256
|
+
|
|
257
|
+
return humanize_identifier(event_type)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def display_event_details(event_type: str, data: object) -> str:
|
|
261
|
+
if not isinstance(data, dict):
|
|
262
|
+
return compact_text(pretty_value(data), 140)
|
|
263
|
+
|
|
264
|
+
if event_type == "session.start":
|
|
265
|
+
context = data.get("context") if isinstance(data.get("context"), dict) else {}
|
|
266
|
+
producer = compact_text(str(data.get("producer") or ""), 36)
|
|
267
|
+
cwd = compact_text(str(context.get("cwd") or ""), 72)
|
|
268
|
+
return join_non_empty([producer, cwd]) or "Session started"
|
|
269
|
+
|
|
270
|
+
if event_type == "session.model_change":
|
|
271
|
+
return f"Switched to {data.get('newModel') or 'unknown model'}"
|
|
272
|
+
|
|
273
|
+
if event_type.endswith(".message"):
|
|
274
|
+
content = data.get("content") or data.get("transformedContent") or ""
|
|
275
|
+
return compact_text(str(content), 140) or "Message payload"
|
|
276
|
+
|
|
277
|
+
if event_type == "assistant.turn_start":
|
|
278
|
+
return join_non_empty([f"turn {data.get('turnId')}" if data.get("turnId") is not None else "", str(data.get("interactionId") or "")]) or "Assistant turn started"
|
|
279
|
+
|
|
280
|
+
if event_type.startswith("tool.execution"):
|
|
281
|
+
tool_name = str(data.get("toolName") or "tool")
|
|
282
|
+
turn_id = f"turn {data.get('turnId')}" if data.get("turnId") is not None else ""
|
|
283
|
+
if event_type.endswith("complete"):
|
|
284
|
+
success = data.get("success")
|
|
285
|
+
state = "success" if success is True else "failed" if success is False else "completed"
|
|
286
|
+
return join_non_empty([tool_name, turn_id, state])
|
|
287
|
+
return join_non_empty([tool_name, turn_id, "started"])
|
|
288
|
+
|
|
289
|
+
fragments: list[str] = []
|
|
290
|
+
for key, value in data.items():
|
|
291
|
+
if key in {"arguments", "output", "content", "transformedContent"}:
|
|
292
|
+
continue
|
|
293
|
+
if isinstance(value, (dict, list)):
|
|
294
|
+
continue
|
|
295
|
+
fragments.append(f"{key}: {compact_text(str(value), 48)}")
|
|
296
|
+
if len(fragments) == 3:
|
|
297
|
+
break
|
|
298
|
+
return join_non_empty(fragments) or compact_text(pretty_value(data), 140) or "Event payload"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_log_sections(event_type: str, event: dict) -> list[SessionLogSection]:
|
|
302
|
+
data = event.get("data", {})
|
|
303
|
+
sections = [
|
|
304
|
+
SessionLogSection(
|
|
305
|
+
title="Metadata",
|
|
306
|
+
content=pretty_value(
|
|
307
|
+
{
|
|
308
|
+
"type": event_type,
|
|
309
|
+
"timestamp": event.get("timestamp"),
|
|
310
|
+
}
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
if isinstance(data, dict):
|
|
316
|
+
if "arguments" in data:
|
|
317
|
+
sections.append(SessionLogSection(title="Arguments", content=pretty_value(data.get("arguments"))))
|
|
318
|
+
if "output" in data:
|
|
319
|
+
sections.append(SessionLogSection(title="Output", content=pretty_value(data.get("output"))))
|
|
320
|
+
if "content" in data:
|
|
321
|
+
sections.append(SessionLogSection(title="Content", content=pretty_value(data.get("content"))))
|
|
322
|
+
if "transformedContent" in data:
|
|
323
|
+
sections.append(SessionLogSection(title="Transformed Content", content=pretty_value(data.get("transformedContent"))))
|
|
324
|
+
if "toolRequests" in data:
|
|
325
|
+
sections.append(SessionLogSection(title="Tool Requests", content=pretty_value(data.get("toolRequests"))))
|
|
326
|
+
|
|
327
|
+
remaining = {
|
|
328
|
+
key: value
|
|
329
|
+
for key, value in data.items()
|
|
330
|
+
if key not in {"arguments", "output", "content", "transformedContent", "toolRequests"}
|
|
331
|
+
}
|
|
332
|
+
if remaining:
|
|
333
|
+
sections.append(SessionLogSection(title="Data", content=pretty_value(remaining)))
|
|
334
|
+
elif data not in ({}, None, ""):
|
|
335
|
+
sections.append(SessionLogSection(title="Data", content=pretty_value(data)))
|
|
336
|
+
|
|
337
|
+
sections.append(SessionLogSection(title="Raw Event", content=pretty_value(event)))
|
|
338
|
+
return sections
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def pretty_value(value: object) -> str:
|
|
342
|
+
if isinstance(value, str):
|
|
343
|
+
return value
|
|
344
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def compact_text(value: str, limit: int) -> str:
|
|
348
|
+
collapsed = " ".join(value.split())
|
|
349
|
+
if len(collapsed) <= limit:
|
|
350
|
+
return collapsed
|
|
351
|
+
return f"{collapsed[: limit - 1]}..."
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def humanize_identifier(value: str) -> str:
|
|
355
|
+
parts = [part for part in value.replace("_", ".").replace("-", ".").split(".") if part]
|
|
356
|
+
return " ".join(part.capitalize() for part in parts) or "Event"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def join_non_empty(parts: list[str]) -> str:
|
|
360
|
+
return " | ".join(part for part in parts if part)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def build_flow_nodes(events: list[dict], model_name: str) -> list[SessionFlowNode]:
|
|
364
|
+
nodes: list[SessionFlowNode] = []
|
|
365
|
+
pending_tools: dict[str, dict] = {}
|
|
366
|
+
|
|
367
|
+
discovery_events, start_index = split_discovery_events(events)
|
|
368
|
+
if discovery_events:
|
|
369
|
+
nodes.append(build_discovery_node(0, discovery_events))
|
|
370
|
+
|
|
371
|
+
next_index = len(nodes)
|
|
372
|
+
for event_index, event in enumerate(events[start_index:], start=start_index):
|
|
373
|
+
event_type = str(event.get("type") or "event")
|
|
374
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
375
|
+
|
|
376
|
+
if event_type == "tool.execution_start":
|
|
377
|
+
tool_call_id = str(data.get("toolCallId") or "")
|
|
378
|
+
if tool_call_id:
|
|
379
|
+
pending_tools[tool_call_id] = data
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
if event_type in {"hook.start", "hook.end", "assistant.turn_start", "assistant.turn_end", "session.start", "session.model_change", "system.message", "session.shutdown"}:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
if event_type == "user.message":
|
|
386
|
+
if is_internal_user_message(data):
|
|
387
|
+
continue
|
|
388
|
+
nodes.append(build_user_flow_node(next_index, event, event_index))
|
|
389
|
+
next_index += 1
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
if event_type == "assistant.message":
|
|
393
|
+
assistant_nodes = build_assistant_flow_nodes(next_index, event, event_index, model_name)
|
|
394
|
+
nodes.extend(assistant_nodes)
|
|
395
|
+
next_index += len(assistant_nodes)
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
if event_type == "tool.execution_complete":
|
|
399
|
+
tool_call_id = str(data.get("toolCallId") or "")
|
|
400
|
+
started = pending_tools.pop(tool_call_id, None)
|
|
401
|
+
nodes.append(build_tool_flow_node(next_index, event, event_index, started))
|
|
402
|
+
next_index += 1
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
if event_type == "skill.invoked":
|
|
406
|
+
nodes.append(build_skill_flow_node(next_index, event, event_index))
|
|
407
|
+
next_index += 1
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
if event_type == "subagent.selected":
|
|
411
|
+
nodes.append(build_subagent_flow_node(next_index, event, event_index))
|
|
412
|
+
next_index += 1
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
if event_type.startswith("permission."):
|
|
416
|
+
nodes.append(build_permission_flow_node(next_index, event, event_index))
|
|
417
|
+
next_index += 1
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
if event_type == "abort":
|
|
421
|
+
nodes.append(build_abort_flow_node(next_index, event, event_index))
|
|
422
|
+
next_index += 1
|
|
423
|
+
|
|
424
|
+
return nodes
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def split_discovery_events(events: list[dict]) -> tuple[list[tuple[int, dict]], int]:
|
|
428
|
+
discovery_events: list[tuple[int, dict]] = []
|
|
429
|
+
index = 0
|
|
430
|
+
while index < len(events):
|
|
431
|
+
event = events[index]
|
|
432
|
+
event_type = str(event.get("type") or "event")
|
|
433
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
434
|
+
if event_type in {"session.start", "session.model_change", "subagent.selected", "system.message"}:
|
|
435
|
+
discovery_events.append((index, event))
|
|
436
|
+
index += 1
|
|
437
|
+
continue
|
|
438
|
+
if event_type == "user.message" and is_internal_user_message(data):
|
|
439
|
+
discovery_events.append((index, event))
|
|
440
|
+
index += 1
|
|
441
|
+
continue
|
|
442
|
+
break
|
|
443
|
+
return discovery_events, index
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def build_discovery_node(index: int, events: list[tuple[int, dict]]) -> SessionFlowNode:
|
|
447
|
+
agent_name = next(
|
|
448
|
+
(
|
|
449
|
+
str((event.get("data") or {}).get("agentDisplayName") or (event.get("data") or {}).get("agentName") or "")
|
|
450
|
+
for _, event in events
|
|
451
|
+
if event.get("type") == "subagent.selected" and isinstance(event.get("data"), dict)
|
|
452
|
+
),
|
|
453
|
+
"",
|
|
454
|
+
)
|
|
455
|
+
labels = [flow_event_label(event) for _, event in events]
|
|
456
|
+
detail = " · ".join(label for label in ([agent_name] if agent_name else []) + [label for label in labels if label][:3])
|
|
457
|
+
subtitle = f"{len(events)} discovery steps"
|
|
458
|
+
return SessionFlowNode(
|
|
459
|
+
index=index,
|
|
460
|
+
kind="group",
|
|
461
|
+
title="Agent Discovery",
|
|
462
|
+
subtitle=subtitle,
|
|
463
|
+
detail=detail,
|
|
464
|
+
meta=format_log_timestamp(str(events[0][1].get("timestamp") or "")),
|
|
465
|
+
log_index=events[0][0],
|
|
466
|
+
status="muted",
|
|
467
|
+
count=len(events),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def build_user_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
472
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
473
|
+
content = compact_text(str(data.get("content") or data.get("transformedContent") or "User input"), 220)
|
|
474
|
+
return SessionFlowNode(
|
|
475
|
+
index=index,
|
|
476
|
+
kind="user",
|
|
477
|
+
title="User Message",
|
|
478
|
+
subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
479
|
+
detail=content,
|
|
480
|
+
meta="",
|
|
481
|
+
log_index=event_index,
|
|
482
|
+
status="accent",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def build_assistant_flow_nodes(index: int, event: dict, event_index: int, model_name: str) -> list[SessionFlowNode]:
|
|
487
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
488
|
+
output_tokens = int(data.get("outputTokens", 0) or 0)
|
|
489
|
+
tool_requests = data.get("toolRequests") if isinstance(data.get("toolRequests"), list) else []
|
|
490
|
+
content = str(data.get("content") or "").strip()
|
|
491
|
+
if not content:
|
|
492
|
+
content = f"Requested {len(tool_requests)} tool calls" if tool_requests else "Assistant emitted a structured response"
|
|
493
|
+
turn_id = data.get("turnId")
|
|
494
|
+
model_subtitle = ["assistant.message"]
|
|
495
|
+
if output_tokens:
|
|
496
|
+
model_subtitle.append(f"{output_tokens} output tokens")
|
|
497
|
+
model_subtitle.append(format_log_timestamp(str(event.get("timestamp") or "")))
|
|
498
|
+
model_detail = join_non_empty(
|
|
499
|
+
[
|
|
500
|
+
f"turn {turn_id}" if turn_id is not None else "",
|
|
501
|
+
f"{len(tool_requests)} tool requests" if tool_requests else "",
|
|
502
|
+
]
|
|
503
|
+
) or "Model turn completed"
|
|
504
|
+
|
|
505
|
+
return [
|
|
506
|
+
SessionFlowNode(
|
|
507
|
+
index=index,
|
|
508
|
+
kind="model",
|
|
509
|
+
title=model_name,
|
|
510
|
+
subtitle=join_non_empty(model_subtitle),
|
|
511
|
+
detail=model_detail,
|
|
512
|
+
meta="",
|
|
513
|
+
log_index=event_index,
|
|
514
|
+
status="accent",
|
|
515
|
+
),
|
|
516
|
+
SessionFlowNode(
|
|
517
|
+
index=index + 1,
|
|
518
|
+
kind="response",
|
|
519
|
+
title="Agent Response",
|
|
520
|
+
subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
521
|
+
detail=compact_text(content, 260),
|
|
522
|
+
meta="",
|
|
523
|
+
log_index=event_index,
|
|
524
|
+
status="neutral",
|
|
525
|
+
),
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def build_tool_flow_node(index: int, event: dict, event_index: int, started: dict | None) -> SessionFlowNode:
|
|
530
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
531
|
+
started = started or {}
|
|
532
|
+
tool_name = str(started.get("toolName") or (data.get("toolTelemetry") or {}).get("displayTitle") or "Tool")
|
|
533
|
+
arguments = pretty_value(started.get("arguments")) if "arguments" in started else ""
|
|
534
|
+
result = data.get("result")
|
|
535
|
+
detail = compact_text(arguments or pretty_value(result), 220)
|
|
536
|
+
success = data.get("success") is not False
|
|
537
|
+
turn_id = started.get("turnId") or data.get("turnId")
|
|
538
|
+
subtitle_parts = ["success" if success else "failed"]
|
|
539
|
+
if turn_id is not None:
|
|
540
|
+
subtitle_parts.append(f"turn {turn_id}")
|
|
541
|
+
return SessionFlowNode(
|
|
542
|
+
index=index,
|
|
543
|
+
kind="tool",
|
|
544
|
+
title=tool_name,
|
|
545
|
+
subtitle=join_non_empty(subtitle_parts),
|
|
546
|
+
detail=detail or "Tool execution completed",
|
|
547
|
+
meta=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
548
|
+
log_index=event_index,
|
|
549
|
+
status="success" if success else "error",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def build_skill_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
554
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
555
|
+
name = str(data.get("name") or "Skill")
|
|
556
|
+
description = compact_text(str(data.get("description") or "Skill invoked"), 220)
|
|
557
|
+
return SessionFlowNode(
|
|
558
|
+
index=index,
|
|
559
|
+
kind="skill",
|
|
560
|
+
title=name,
|
|
561
|
+
subtitle="skill.invoked",
|
|
562
|
+
detail=description,
|
|
563
|
+
meta=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
564
|
+
log_index=event_index,
|
|
565
|
+
status="success",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def build_subagent_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
570
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
571
|
+
tools = data.get("tools") if isinstance(data.get("tools"), list) else []
|
|
572
|
+
tool_summary = f"{len(tools)} tools available" if tools else "Subagent selected"
|
|
573
|
+
return SessionFlowNode(
|
|
574
|
+
index=index,
|
|
575
|
+
kind="agent",
|
|
576
|
+
title=str(data.get("agentDisplayName") or data.get("agentName") or "Subagent"),
|
|
577
|
+
subtitle="subagent.selected",
|
|
578
|
+
detail=tool_summary,
|
|
579
|
+
meta=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
580
|
+
log_index=event_index,
|
|
581
|
+
status="muted",
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def build_permission_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
586
|
+
event_type = str(event.get("type") or "permission")
|
|
587
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
588
|
+
return SessionFlowNode(
|
|
589
|
+
index=index,
|
|
590
|
+
kind="state",
|
|
591
|
+
title=humanize_identifier(event_type),
|
|
592
|
+
subtitle=str(data.get("toolName") or event_type),
|
|
593
|
+
detail=compact_text(pretty_value(data), 180),
|
|
594
|
+
meta=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
595
|
+
log_index=event_index,
|
|
596
|
+
status="muted",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def build_abort_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
601
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
602
|
+
return SessionFlowNode(
|
|
603
|
+
index=index,
|
|
604
|
+
kind="state",
|
|
605
|
+
title="Abort",
|
|
606
|
+
subtitle=str(data.get("reason") or "Session aborted"),
|
|
607
|
+
detail=compact_text(pretty_value(data), 180),
|
|
608
|
+
meta=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
609
|
+
log_index=event_index,
|
|
610
|
+
status="error",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def flow_event_label(event: dict) -> str:
|
|
615
|
+
event_type = str(event.get("type") or "event")
|
|
616
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
617
|
+
if event_type == "subagent.selected":
|
|
618
|
+
return str(data.get("agentDisplayName") or data.get("agentName") or "Subagent")
|
|
619
|
+
if event_type == "session.model_change":
|
|
620
|
+
return str(data.get("newModel") or "Model selected")
|
|
621
|
+
if event_type == "user.message" and is_internal_user_message(data):
|
|
622
|
+
return "Skill Context"
|
|
623
|
+
return humanize_identifier(event_type)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def is_internal_user_message(data: dict) -> bool:
|
|
627
|
+
content = str(data.get("content") or "")
|
|
628
|
+
return content.lstrip().startswith("<skill-context")
|