glaip-sdk 0.0.19__py3-none-any.whl → 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.
- glaip_sdk/_version.py +2 -2
- glaip_sdk/branding.py +27 -2
- glaip_sdk/cli/auth.py +93 -28
- glaip_sdk/cli/commands/__init__.py +2 -2
- glaip_sdk/cli/commands/agents.py +127 -21
- glaip_sdk/cli/commands/configure.py +141 -90
- glaip_sdk/cli/commands/mcps.py +82 -31
- glaip_sdk/cli/commands/models.py +4 -3
- glaip_sdk/cli/commands/tools.py +27 -14
- glaip_sdk/cli/commands/update.py +66 -0
- glaip_sdk/cli/config.py +13 -2
- glaip_sdk/cli/display.py +35 -26
- glaip_sdk/cli/io.py +14 -5
- glaip_sdk/cli/main.py +185 -73
- glaip_sdk/cli/pager.py +2 -1
- glaip_sdk/cli/resolution.py +4 -1
- glaip_sdk/cli/slash/__init__.py +3 -4
- glaip_sdk/cli/slash/agent_session.py +88 -36
- glaip_sdk/cli/slash/prompt.py +20 -48
- glaip_sdk/cli/slash/session.py +437 -189
- glaip_sdk/cli/transcript/__init__.py +71 -0
- glaip_sdk/cli/transcript/cache.py +338 -0
- glaip_sdk/cli/transcript/capture.py +278 -0
- glaip_sdk/cli/transcript/export.py +38 -0
- glaip_sdk/cli/transcript/launcher.py +79 -0
- glaip_sdk/cli/transcript/viewer.py +794 -0
- glaip_sdk/cli/update_notifier.py +29 -5
- glaip_sdk/cli/utils.py +255 -74
- glaip_sdk/client/agents.py +3 -1
- glaip_sdk/client/run_rendering.py +126 -21
- glaip_sdk/icons.py +25 -0
- glaip_sdk/models.py +6 -0
- glaip_sdk/rich_components.py +29 -1
- glaip_sdk/utils/__init__.py +1 -1
- glaip_sdk/utils/client_utils.py +6 -4
- glaip_sdk/utils/display.py +61 -32
- glaip_sdk/utils/rendering/formatting.py +55 -11
- glaip_sdk/utils/rendering/models.py +15 -2
- glaip_sdk/utils/rendering/renderer/__init__.py +0 -2
- glaip_sdk/utils/rendering/renderer/base.py +1287 -227
- glaip_sdk/utils/rendering/renderer/config.py +3 -5
- glaip_sdk/utils/rendering/renderer/debug.py +73 -16
- glaip_sdk/utils/rendering/renderer/panels.py +27 -15
- glaip_sdk/utils/rendering/renderer/progress.py +61 -38
- glaip_sdk/utils/rendering/renderer/stream.py +3 -3
- glaip_sdk/utils/rendering/renderer/toggle.py +184 -0
- glaip_sdk/utils/rendering/step_tree_state.py +102 -0
- glaip_sdk/utils/rendering/steps.py +944 -16
- glaip_sdk/utils/serialization.py +5 -2
- glaip_sdk/utils/validation.py +1 -2
- {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/METADATA +12 -1
- glaip_sdk-0.1.0.dist-info/RECORD +82 -0
- glaip_sdk/utils/rich_utils.py +0 -29
- glaip_sdk-0.0.19.dist-info/RECORD +0 -73
- {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""Interactive viewer for post-run transcript exploration.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markdown import Markdown
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
try: # pragma: no cover - optional dependency
|
|
21
|
+
import questionary
|
|
22
|
+
from questionary import Choice
|
|
23
|
+
except Exception: # pragma: no cover - optional dependency
|
|
24
|
+
questionary = None # type: ignore[assignment]
|
|
25
|
+
Choice = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
from glaip_sdk.cli.transcript.cache import suggest_filename
|
|
28
|
+
from glaip_sdk.icons import ICON_DELEGATE, ICON_TOOL_STEP
|
|
29
|
+
from glaip_sdk.rich_components import AIPPanel
|
|
30
|
+
from glaip_sdk.utils.rendering.formatting import (
|
|
31
|
+
build_connector_prefix,
|
|
32
|
+
glyph_for_status,
|
|
33
|
+
normalise_display_label,
|
|
34
|
+
)
|
|
35
|
+
from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
|
|
36
|
+
from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
|
|
37
|
+
from glaip_sdk.utils.rendering.renderer.progress import (
|
|
38
|
+
format_elapsed_time,
|
|
39
|
+
is_delegation_tool,
|
|
40
|
+
)
|
|
41
|
+
from glaip_sdk.utils.rendering.steps import StepManager
|
|
42
|
+
|
|
43
|
+
EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class ViewerContext:
|
|
48
|
+
"""Runtime context for the viewer session."""
|
|
49
|
+
|
|
50
|
+
manifest_entry: dict[str, Any]
|
|
51
|
+
events: list[dict[str, Any]]
|
|
52
|
+
default_output: str
|
|
53
|
+
final_output: str
|
|
54
|
+
stream_started_at: float | None
|
|
55
|
+
meta: dict[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
|
|
59
|
+
"""Simple interactive session for inspecting agent run transcripts."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
console: Console,
|
|
64
|
+
ctx: ViewerContext,
|
|
65
|
+
export_callback: Callable[[Path], Path],
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialize viewer state for a captured transcript."""
|
|
68
|
+
self.console = console
|
|
69
|
+
self.ctx = ctx
|
|
70
|
+
self._export_callback = export_callback
|
|
71
|
+
self._view_mode = "default"
|
|
72
|
+
|
|
73
|
+
def run(self) -> None:
|
|
74
|
+
"""Enter the interactive loop."""
|
|
75
|
+
if not self.ctx.events and not (
|
|
76
|
+
self.ctx.default_output or self.ctx.final_output
|
|
77
|
+
):
|
|
78
|
+
return
|
|
79
|
+
if self._view_mode == "transcript":
|
|
80
|
+
self._render()
|
|
81
|
+
self._print_command_hint()
|
|
82
|
+
self._fallback_loop()
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Rendering helpers
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
def _render(self) -> None:
|
|
88
|
+
try:
|
|
89
|
+
if self.console.is_terminal:
|
|
90
|
+
self.console.clear()
|
|
91
|
+
except Exception: # pragma: no cover - platform quirks
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
header = (
|
|
95
|
+
f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
|
|
96
|
+
)
|
|
97
|
+
agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
|
|
98
|
+
model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
|
|
99
|
+
agent_id = self.ctx.manifest_entry.get("agent_id")
|
|
100
|
+
subtitle_parts = [agent_label]
|
|
101
|
+
if model:
|
|
102
|
+
subtitle_parts.append(str(model))
|
|
103
|
+
if agent_id:
|
|
104
|
+
subtitle_parts.append(agent_id)
|
|
105
|
+
|
|
106
|
+
if self._view_mode == "transcript":
|
|
107
|
+
self.console.rule(header)
|
|
108
|
+
if subtitle_parts:
|
|
109
|
+
self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
|
|
110
|
+
self.console.print()
|
|
111
|
+
|
|
112
|
+
query = self._get_user_query()
|
|
113
|
+
|
|
114
|
+
if self._view_mode == "default":
|
|
115
|
+
self._render_default_view(query)
|
|
116
|
+
else:
|
|
117
|
+
self._render_transcript_view(query)
|
|
118
|
+
|
|
119
|
+
def _render_default_view(self, query: str | None) -> None:
|
|
120
|
+
if query:
|
|
121
|
+
self._render_user_query(query)
|
|
122
|
+
self._render_steps_summary()
|
|
123
|
+
self._render_final_panel()
|
|
124
|
+
|
|
125
|
+
def _render_transcript_view(self, query: str | None) -> None:
|
|
126
|
+
if not self.ctx.events:
|
|
127
|
+
self.console.print("[dim]No SSE events were captured for this run.[/dim]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if query:
|
|
131
|
+
self._render_user_query(query)
|
|
132
|
+
|
|
133
|
+
self._render_steps_summary()
|
|
134
|
+
self._render_final_panel()
|
|
135
|
+
|
|
136
|
+
self.console.print("[bold]Transcript Events[/bold]")
|
|
137
|
+
self.console.print(
|
|
138
|
+
"[dim]────────────────────────────────────────────────────────[/dim]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
base_received_ts: datetime | None = None
|
|
142
|
+
for event in self.ctx.events:
|
|
143
|
+
received_ts = self._parse_received_timestamp(event)
|
|
144
|
+
if base_received_ts is None and received_ts is not None:
|
|
145
|
+
base_received_ts = received_ts
|
|
146
|
+
render_debug_event(
|
|
147
|
+
event,
|
|
148
|
+
self.console,
|
|
149
|
+
received_ts=received_ts,
|
|
150
|
+
baseline_ts=base_received_ts,
|
|
151
|
+
)
|
|
152
|
+
self.console.print()
|
|
153
|
+
|
|
154
|
+
def _render_final_panel(self) -> None:
|
|
155
|
+
content = (
|
|
156
|
+
self.ctx.final_output
|
|
157
|
+
or self.ctx.default_output
|
|
158
|
+
or "No response content captured."
|
|
159
|
+
)
|
|
160
|
+
title = "Final Result"
|
|
161
|
+
duration_text = self._extract_final_duration()
|
|
162
|
+
if duration_text:
|
|
163
|
+
title += f" · {duration_text}"
|
|
164
|
+
panel = create_final_panel(content, title=title, theme="dark")
|
|
165
|
+
self.console.print(panel)
|
|
166
|
+
self.console.print()
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Interaction loops
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
def _fallback_loop(self) -> None:
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
ch = click.getchar()
|
|
175
|
+
except (EOFError, KeyboardInterrupt):
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if ch in {"\r", "\n"}:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if ch == "\x14" or ch.lower() == "t": # Ctrl+T or t
|
|
182
|
+
self.toggle_view()
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if ch.lower() == "e":
|
|
186
|
+
self.export_transcript()
|
|
187
|
+
self._print_command_hint()
|
|
188
|
+
else:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
def _handle_command(self, raw: str) -> bool:
|
|
192
|
+
lowered = raw.lower()
|
|
193
|
+
if lowered in {"exit", "quit", "q"}:
|
|
194
|
+
return True
|
|
195
|
+
if lowered in {"export", "e"}:
|
|
196
|
+
self.export_transcript()
|
|
197
|
+
self._print_command_hint()
|
|
198
|
+
return False
|
|
199
|
+
self.console.print("[dim]Commands: export, exit.[/dim]")
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Actions
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
def toggle_view(self) -> None:
|
|
206
|
+
"""Switch between default result view and verbose transcript."""
|
|
207
|
+
self._view_mode = "transcript" if self._view_mode == "default" else "default"
|
|
208
|
+
self._render()
|
|
209
|
+
self._print_command_hint()
|
|
210
|
+
|
|
211
|
+
def export_transcript(self) -> None:
|
|
212
|
+
"""Prompt user for a destination and export the cached transcript."""
|
|
213
|
+
entry = self.ctx.manifest_entry
|
|
214
|
+
default_name = suggest_filename(entry)
|
|
215
|
+
default_path = Path.cwd() / default_name
|
|
216
|
+
|
|
217
|
+
def _display_path(path: Path) -> str:
|
|
218
|
+
raw = str(path)
|
|
219
|
+
return raw if len(raw) <= 80 else f"…{raw[-77:]}"
|
|
220
|
+
|
|
221
|
+
selection = self._prompt_export_choice(
|
|
222
|
+
default_path, _display_path(default_path)
|
|
223
|
+
)
|
|
224
|
+
if selection is None:
|
|
225
|
+
self._legacy_export_prompt(default_path, _display_path)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
action, _ = selection
|
|
229
|
+
if action == "cancel":
|
|
230
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if action == "default":
|
|
234
|
+
destination = default_path
|
|
235
|
+
else:
|
|
236
|
+
destination = self._prompt_custom_destination()
|
|
237
|
+
if destination is None:
|
|
238
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
target = self._export_callback(destination)
|
|
243
|
+
self.console.print(f"[green]Transcript exported to {target}[/green]")
|
|
244
|
+
except FileNotFoundError as exc:
|
|
245
|
+
self.console.print(f"[red]{exc}[/red]")
|
|
246
|
+
except Exception as exc: # pragma: no cover - unexpected IO failures
|
|
247
|
+
self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
|
|
248
|
+
|
|
249
|
+
def _prompt_export_choice(
|
|
250
|
+
self, default_path: Path, default_display: str
|
|
251
|
+
) -> tuple[str, Any] | None:
|
|
252
|
+
"""Render interactive export menu with numeric shortcuts."""
|
|
253
|
+
if not self.console.is_terminal or questionary is None or Choice is None:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
answer = questionary.select(
|
|
258
|
+
"Export transcript",
|
|
259
|
+
choices=[
|
|
260
|
+
Choice(
|
|
261
|
+
title=f"Save to default ({default_display})",
|
|
262
|
+
value=("default", default_path),
|
|
263
|
+
shortcut_key="1",
|
|
264
|
+
),
|
|
265
|
+
Choice(
|
|
266
|
+
title="Choose a different path",
|
|
267
|
+
value=("custom", None),
|
|
268
|
+
shortcut_key="2",
|
|
269
|
+
),
|
|
270
|
+
Choice(
|
|
271
|
+
title="Cancel",
|
|
272
|
+
value=("cancel", None),
|
|
273
|
+
shortcut_key="3",
|
|
274
|
+
),
|
|
275
|
+
],
|
|
276
|
+
use_shortcuts=True,
|
|
277
|
+
instruction="Press 1-3 (or arrows) then Enter.",
|
|
278
|
+
).ask()
|
|
279
|
+
except Exception:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
if answer is None:
|
|
283
|
+
return ("cancel", None)
|
|
284
|
+
return answer
|
|
285
|
+
|
|
286
|
+
def _prompt_custom_destination(self) -> Path | None:
|
|
287
|
+
"""Prompt for custom export path with filesystem completion."""
|
|
288
|
+
if not self.console.is_terminal:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
response = questionary.path(
|
|
293
|
+
"Destination path (Tab to autocomplete):",
|
|
294
|
+
default="",
|
|
295
|
+
only_directories=False,
|
|
296
|
+
).ask()
|
|
297
|
+
except Exception:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
if not response:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
candidate = Path(response.strip()).expanduser()
|
|
304
|
+
if not candidate.is_absolute():
|
|
305
|
+
candidate = Path.cwd() / candidate
|
|
306
|
+
return candidate
|
|
307
|
+
|
|
308
|
+
def _legacy_export_prompt(
|
|
309
|
+
self, default_path: Path, formatter: Callable[[Path], str]
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Fallback export workflow when interactive UI is unavailable."""
|
|
312
|
+
self.console.print("[dim]Export options (fallback mode)[/dim]")
|
|
313
|
+
self.console.print(f" 1. Save to default ({formatter(default_path)})")
|
|
314
|
+
self.console.print(" 2. Choose a different path")
|
|
315
|
+
self.console.print(" 3. Cancel")
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
choice = click.prompt(
|
|
319
|
+
"Select option",
|
|
320
|
+
type=click.Choice(["1", "2", "3"], case_sensitive=False),
|
|
321
|
+
default="1",
|
|
322
|
+
show_choices=False,
|
|
323
|
+
)
|
|
324
|
+
except (EOFError, KeyboardInterrupt):
|
|
325
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
if choice == "3":
|
|
329
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
if choice == "1":
|
|
333
|
+
destination = default_path
|
|
334
|
+
else:
|
|
335
|
+
try:
|
|
336
|
+
destination_str = click.prompt("Enter destination path", default="")
|
|
337
|
+
except (EOFError, KeyboardInterrupt):
|
|
338
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
339
|
+
return
|
|
340
|
+
if not destination_str.strip():
|
|
341
|
+
self.console.print(EXPORT_CANCELLED_MESSAGE)
|
|
342
|
+
return
|
|
343
|
+
destination = Path(destination_str.strip()).expanduser()
|
|
344
|
+
if not destination.is_absolute():
|
|
345
|
+
destination = Path.cwd() / destination
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
target = self._export_callback(destination)
|
|
349
|
+
self.console.print(f"[green]Transcript exported to {target}[/green]")
|
|
350
|
+
except FileNotFoundError as exc:
|
|
351
|
+
self.console.print(f"[red]{exc}[/red]")
|
|
352
|
+
except Exception as exc: # pragma: no cover - unexpected IO failures
|
|
353
|
+
self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
|
|
354
|
+
|
|
355
|
+
def _print_command_hint(self) -> None:
|
|
356
|
+
self.console.print(
|
|
357
|
+
"[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]"
|
|
358
|
+
)
|
|
359
|
+
self.console.print()
|
|
360
|
+
|
|
361
|
+
def _get_user_query(self) -> str | None:
|
|
362
|
+
meta = self.ctx.meta or {}
|
|
363
|
+
manifest = self.ctx.manifest_entry or {}
|
|
364
|
+
return (
|
|
365
|
+
meta.get("input_message")
|
|
366
|
+
or meta.get("query")
|
|
367
|
+
or meta.get("message")
|
|
368
|
+
or manifest.get("input_message")
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _render_user_query(self, query: str) -> None:
|
|
372
|
+
panel = AIPPanel(
|
|
373
|
+
Markdown(f"Query: {query}"),
|
|
374
|
+
title="User Request",
|
|
375
|
+
border_style="#d97706",
|
|
376
|
+
)
|
|
377
|
+
self.console.print(panel)
|
|
378
|
+
self.console.print()
|
|
379
|
+
|
|
380
|
+
def _render_steps_summary(self) -> None:
|
|
381
|
+
tree_text = self._build_tree_summary_text()
|
|
382
|
+
if tree_text is not None:
|
|
383
|
+
body = tree_text
|
|
384
|
+
else:
|
|
385
|
+
panel_content = self._format_steps_summary(self._build_step_summary())
|
|
386
|
+
body = Text(panel_content, style="dim")
|
|
387
|
+
panel = AIPPanel(body, title="Steps", border_style="blue")
|
|
388
|
+
self.console.print(panel)
|
|
389
|
+
self.console.print()
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _format_steps_summary(steps: list[dict[str, Any]]) -> str:
|
|
393
|
+
if not steps:
|
|
394
|
+
return " No steps yet"
|
|
395
|
+
|
|
396
|
+
lines = []
|
|
397
|
+
for step in steps:
|
|
398
|
+
icon = ICON_DELEGATE if step.get("is_delegate") else ICON_TOOL_STEP
|
|
399
|
+
duration = step.get("duration")
|
|
400
|
+
duration_str = f" [{duration}]" if duration else ""
|
|
401
|
+
status = " ✓" if step.get("finished") else ""
|
|
402
|
+
title = step.get("title") or step.get("name") or "Step"
|
|
403
|
+
lines.append(f" {icon} {title}{duration_str}{status}")
|
|
404
|
+
return "\n".join(lines)
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _extract_event_time(event: dict[str, Any]) -> float | None:
|
|
408
|
+
metadata = event.get("metadata") or {}
|
|
409
|
+
time_value = metadata.get("time")
|
|
410
|
+
try:
|
|
411
|
+
if isinstance(time_value, (int, float)):
|
|
412
|
+
return float(time_value)
|
|
413
|
+
except Exception:
|
|
414
|
+
return None
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _parse_received_timestamp(event: dict[str, Any]) -> datetime | None:
|
|
419
|
+
value = event.get("received_at")
|
|
420
|
+
if not value:
|
|
421
|
+
return None
|
|
422
|
+
if isinstance(value, str):
|
|
423
|
+
try:
|
|
424
|
+
normalised = value.replace("Z", "+00:00")
|
|
425
|
+
parsed = datetime.fromisoformat(normalised)
|
|
426
|
+
except ValueError:
|
|
427
|
+
return None
|
|
428
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
def _extract_final_duration(self) -> str | None:
|
|
432
|
+
for event in self.ctx.events:
|
|
433
|
+
metadata = event.get("metadata") or {}
|
|
434
|
+
if metadata.get("kind") == "final_response":
|
|
435
|
+
time_value = metadata.get("time")
|
|
436
|
+
try:
|
|
437
|
+
if isinstance(time_value, (int, float)):
|
|
438
|
+
return f"{float(time_value):.2f}s"
|
|
439
|
+
except Exception:
|
|
440
|
+
return None
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def _build_step_summary(self) -> list[dict[str, Any]]:
|
|
444
|
+
stored = self.ctx.meta.get("transcript_steps")
|
|
445
|
+
if isinstance(stored, list) and stored:
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
"title": entry.get("display_name") or entry.get("name") or "Step",
|
|
449
|
+
"is_delegate": entry.get("kind") == "delegate",
|
|
450
|
+
"finished": entry.get("status") == "finished",
|
|
451
|
+
"duration": self._format_duration_from_ms(entry.get("duration_ms")),
|
|
452
|
+
}
|
|
453
|
+
for entry in stored
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
steps: dict[str, dict[str, Any]] = {}
|
|
457
|
+
order: list[str] = []
|
|
458
|
+
|
|
459
|
+
for event in self.ctx.events:
|
|
460
|
+
metadata = event.get("metadata") or {}
|
|
461
|
+
if not self._is_step_event(metadata):
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
for name, info in self._iter_step_candidates(event, metadata):
|
|
465
|
+
step = self._ensure_step_entry(steps, order, name)
|
|
466
|
+
self._apply_step_update(step, metadata, info, event)
|
|
467
|
+
|
|
468
|
+
return [steps[name] for name in order]
|
|
469
|
+
|
|
470
|
+
def _build_tree_summary_text(self) -> Text | None:
|
|
471
|
+
"""Render hierarchical tree from captured SSE events when available."""
|
|
472
|
+
manager = StepManager()
|
|
473
|
+
processed = False
|
|
474
|
+
|
|
475
|
+
for event in self.ctx.events:
|
|
476
|
+
payload = self._coerce_step_event(event)
|
|
477
|
+
if not payload:
|
|
478
|
+
continue
|
|
479
|
+
try:
|
|
480
|
+
manager.apply_event(payload)
|
|
481
|
+
processed = True
|
|
482
|
+
except ValueError:
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
if not processed or not manager.order:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
lines: list[str] = []
|
|
489
|
+
roots = manager.order
|
|
490
|
+
total_roots = len(roots)
|
|
491
|
+
for index, root_id in enumerate(roots):
|
|
492
|
+
self._render_tree_branch(
|
|
493
|
+
manager=manager,
|
|
494
|
+
step_id=root_id,
|
|
495
|
+
ancestor_state=(),
|
|
496
|
+
is_last=index == total_roots - 1,
|
|
497
|
+
lines=lines,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if not lines:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
return Text("\n".join(lines), style="dim")
|
|
504
|
+
|
|
505
|
+
def _render_tree_branch(
|
|
506
|
+
self,
|
|
507
|
+
*,
|
|
508
|
+
manager: StepManager,
|
|
509
|
+
step_id: str,
|
|
510
|
+
ancestor_state: tuple[bool, ...],
|
|
511
|
+
is_last: bool,
|
|
512
|
+
lines: list[str],
|
|
513
|
+
) -> None:
|
|
514
|
+
step = manager.by_id.get(step_id)
|
|
515
|
+
if not step:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
suppress = self._should_hide_step(step)
|
|
519
|
+
children = manager.children.get(step_id, [])
|
|
520
|
+
|
|
521
|
+
if not suppress:
|
|
522
|
+
branch_state = ancestor_state
|
|
523
|
+
if branch_state:
|
|
524
|
+
branch_state = branch_state + (is_last,)
|
|
525
|
+
lines.append(self._format_tree_line(step, branch_state))
|
|
526
|
+
next_ancestor_state = ancestor_state + (is_last,)
|
|
527
|
+
else:
|
|
528
|
+
next_ancestor_state = ancestor_state
|
|
529
|
+
|
|
530
|
+
if not children:
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
total_children = len(children)
|
|
534
|
+
for idx, child_id in enumerate(children):
|
|
535
|
+
self._render_tree_branch(
|
|
536
|
+
manager=manager,
|
|
537
|
+
step_id=child_id,
|
|
538
|
+
ancestor_state=next_ancestor_state if not suppress else ancestor_state,
|
|
539
|
+
is_last=idx == total_children - 1,
|
|
540
|
+
lines=lines,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def _should_hide_step(self, step: Any) -> bool:
|
|
544
|
+
if getattr(step, "parent_id", None) is not None:
|
|
545
|
+
return False
|
|
546
|
+
if getattr(step, "kind", None) == "thinking":
|
|
547
|
+
return True
|
|
548
|
+
if getattr(step, "kind", None) == "agent":
|
|
549
|
+
return True
|
|
550
|
+
name = getattr(step, "name", "") or ""
|
|
551
|
+
return self._looks_like_uuid(name)
|
|
552
|
+
|
|
553
|
+
def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
554
|
+
metadata = event.get("metadata")
|
|
555
|
+
if not isinstance(metadata, dict):
|
|
556
|
+
return None
|
|
557
|
+
if not isinstance(metadata.get("step_id"), str):
|
|
558
|
+
return None
|
|
559
|
+
return {
|
|
560
|
+
"metadata": metadata,
|
|
561
|
+
"status": event.get("status"),
|
|
562
|
+
"task_state": event.get("task_state"),
|
|
563
|
+
"content": event.get("content"),
|
|
564
|
+
"task_id": event.get("task_id"),
|
|
565
|
+
"context_id": event.get("context_id"),
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
|
|
569
|
+
prefix = build_connector_prefix(branch_state)
|
|
570
|
+
raw_label = normalise_display_label(getattr(step, "display_label", None))
|
|
571
|
+
title, summary = self._split_label(raw_label)
|
|
572
|
+
line = f"{prefix}{title}"
|
|
573
|
+
|
|
574
|
+
if summary:
|
|
575
|
+
line += f" — {self._truncate_summary(summary)}"
|
|
576
|
+
|
|
577
|
+
badge = self._format_duration_badge(step)
|
|
578
|
+
if badge:
|
|
579
|
+
line += f" {badge}"
|
|
580
|
+
|
|
581
|
+
glyph = glyph_for_status(getattr(step, "status_icon", None))
|
|
582
|
+
failure_reason = getattr(step, "failure_reason", None)
|
|
583
|
+
if glyph and glyph != "spinner":
|
|
584
|
+
if failure_reason and glyph == "✗":
|
|
585
|
+
line += f" {glyph} {failure_reason}"
|
|
586
|
+
else:
|
|
587
|
+
line += f" {glyph}"
|
|
588
|
+
elif failure_reason:
|
|
589
|
+
line += f" ✗ {failure_reason}"
|
|
590
|
+
|
|
591
|
+
return line
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def _format_duration_badge(step: Any) -> str | None:
|
|
595
|
+
duration_ms = getattr(step, "duration_ms", None)
|
|
596
|
+
if duration_ms is None:
|
|
597
|
+
return None
|
|
598
|
+
try:
|
|
599
|
+
duration_ms = int(duration_ms)
|
|
600
|
+
except Exception:
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
if duration_ms <= 0:
|
|
604
|
+
payload = "<1ms"
|
|
605
|
+
elif duration_ms >= 1000:
|
|
606
|
+
payload = f"{duration_ms / 1000:.2f}s"
|
|
607
|
+
else:
|
|
608
|
+
payload = f"{duration_ms}ms"
|
|
609
|
+
|
|
610
|
+
return f"[{payload}]"
|
|
611
|
+
|
|
612
|
+
@staticmethod
|
|
613
|
+
def _split_label(label: str) -> tuple[str, str | None]:
|
|
614
|
+
if " — " in label:
|
|
615
|
+
title, summary = label.split(" — ", 1)
|
|
616
|
+
return title.strip(), summary.strip()
|
|
617
|
+
return label.strip(), None
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _truncate_summary(summary: str, limit: int = 48) -> str:
|
|
621
|
+
summary = summary.strip()
|
|
622
|
+
if len(summary) <= limit:
|
|
623
|
+
return summary
|
|
624
|
+
return summary[: limit - 1].rstrip() + "…"
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _looks_like_uuid(value: str) -> bool:
|
|
628
|
+
stripped = value.replace("-", "")
|
|
629
|
+
if len(stripped) not in {32, 36}:
|
|
630
|
+
return False
|
|
631
|
+
return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _format_duration_from_ms(value: Any) -> str | None:
|
|
635
|
+
try:
|
|
636
|
+
if value is None:
|
|
637
|
+
return None
|
|
638
|
+
duration_ms = float(value)
|
|
639
|
+
except Exception:
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
if duration_ms <= 0:
|
|
643
|
+
return "<1ms"
|
|
644
|
+
if duration_ms < 1000:
|
|
645
|
+
return f"{int(duration_ms)}ms"
|
|
646
|
+
return f"{duration_ms / 1000:.2f}s"
|
|
647
|
+
|
|
648
|
+
@staticmethod
|
|
649
|
+
def _is_step_event(metadata: dict[str, Any]) -> bool:
|
|
650
|
+
kind = metadata.get("kind")
|
|
651
|
+
return kind in {"agent_step", "agent_thinking_step"}
|
|
652
|
+
|
|
653
|
+
def _iter_step_candidates(
|
|
654
|
+
self, event: dict[str, Any], metadata: dict[str, Any]
|
|
655
|
+
) -> Iterable[tuple[str, dict[str, Any]]]:
|
|
656
|
+
tool_info = metadata.get("tool_info") or {}
|
|
657
|
+
|
|
658
|
+
yielded = False
|
|
659
|
+
for candidate in self._iter_tool_call_candidates(tool_info):
|
|
660
|
+
yielded = True
|
|
661
|
+
yield candidate
|
|
662
|
+
|
|
663
|
+
if yielded:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
direct_tool = self._extract_direct_tool(tool_info)
|
|
667
|
+
if direct_tool is not None:
|
|
668
|
+
yield direct_tool
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
completed = self._extract_completed_name(event)
|
|
672
|
+
if completed is not None:
|
|
673
|
+
yield completed, {}
|
|
674
|
+
|
|
675
|
+
@staticmethod
|
|
676
|
+
def _iter_tool_call_candidates(
|
|
677
|
+
tool_info: dict[str, Any],
|
|
678
|
+
) -> Iterable[tuple[str, dict[str, Any]]]:
|
|
679
|
+
tool_calls = tool_info.get("tool_calls")
|
|
680
|
+
if isinstance(tool_calls, list):
|
|
681
|
+
for call in tool_calls:
|
|
682
|
+
name = call.get("name")
|
|
683
|
+
if name:
|
|
684
|
+
yield name, call
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def _extract_direct_tool(
|
|
688
|
+
tool_info: dict[str, Any],
|
|
689
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
690
|
+
if isinstance(tool_info, dict):
|
|
691
|
+
name = tool_info.get("name")
|
|
692
|
+
if name:
|
|
693
|
+
return name, tool_info
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
@staticmethod
|
|
697
|
+
def _extract_completed_name(event: dict[str, Any]) -> str | None:
|
|
698
|
+
content = event.get("content") or ""
|
|
699
|
+
if isinstance(content, str) and content.startswith("Completed "):
|
|
700
|
+
name = content.replace("Completed ", "").strip()
|
|
701
|
+
if name:
|
|
702
|
+
return name
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
def _ensure_step_entry(
|
|
706
|
+
self,
|
|
707
|
+
steps: dict[str, dict[str, Any]],
|
|
708
|
+
order: list[str],
|
|
709
|
+
name: str,
|
|
710
|
+
) -> dict[str, Any]:
|
|
711
|
+
if name not in steps:
|
|
712
|
+
steps[name] = {
|
|
713
|
+
"name": name,
|
|
714
|
+
"title": name,
|
|
715
|
+
"is_delegate": is_delegation_tool(name),
|
|
716
|
+
"duration": None,
|
|
717
|
+
"started_at": None,
|
|
718
|
+
"finished": False,
|
|
719
|
+
}
|
|
720
|
+
order.append(name)
|
|
721
|
+
return steps[name]
|
|
722
|
+
|
|
723
|
+
def _apply_step_update(
|
|
724
|
+
self,
|
|
725
|
+
step: dict[str, Any],
|
|
726
|
+
metadata: dict[str, Any],
|
|
727
|
+
info: dict[str, Any],
|
|
728
|
+
event: dict[str, Any],
|
|
729
|
+
) -> None:
|
|
730
|
+
status = metadata.get("status")
|
|
731
|
+
event_time = metadata.get("time")
|
|
732
|
+
|
|
733
|
+
if (
|
|
734
|
+
status == "running"
|
|
735
|
+
and step.get("started_at") is None
|
|
736
|
+
and isinstance(event_time, (int, float))
|
|
737
|
+
):
|
|
738
|
+
try:
|
|
739
|
+
step["started_at"] = float(event_time)
|
|
740
|
+
except Exception:
|
|
741
|
+
step["started_at"] = None
|
|
742
|
+
|
|
743
|
+
if self._is_step_finished(metadata, event):
|
|
744
|
+
step["finished"] = True
|
|
745
|
+
|
|
746
|
+
duration = self._compute_step_duration(step, info, metadata)
|
|
747
|
+
if duration is not None:
|
|
748
|
+
step["duration"] = duration
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _is_step_finished(metadata: dict[str, Any], event: dict[str, Any]) -> bool:
|
|
752
|
+
status = metadata.get("status")
|
|
753
|
+
return status == "finished" or bool(event.get("final"))
|
|
754
|
+
|
|
755
|
+
def _compute_step_duration(
|
|
756
|
+
self, step: dict[str, Any], info: dict[str, Any], metadata: dict[str, Any]
|
|
757
|
+
) -> str | None:
|
|
758
|
+
"""Calculate a formatted duration string for a step if possible."""
|
|
759
|
+
event_time = metadata.get("time")
|
|
760
|
+
started_at = step.get("started_at")
|
|
761
|
+
duration_value: float | None = None
|
|
762
|
+
|
|
763
|
+
if isinstance(event_time, (int, float)) and isinstance(
|
|
764
|
+
started_at, (int, float)
|
|
765
|
+
):
|
|
766
|
+
try:
|
|
767
|
+
delta = float(event_time) - float(started_at)
|
|
768
|
+
if delta >= 0:
|
|
769
|
+
duration_value = delta
|
|
770
|
+
except Exception:
|
|
771
|
+
duration_value = None
|
|
772
|
+
|
|
773
|
+
if duration_value is None:
|
|
774
|
+
exec_time = info.get("execution_time")
|
|
775
|
+
if isinstance(exec_time, (int, float)):
|
|
776
|
+
duration_value = float(exec_time)
|
|
777
|
+
|
|
778
|
+
if duration_value is None:
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
return format_elapsed_time(duration_value)
|
|
783
|
+
except Exception:
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def run_viewer_session(
|
|
788
|
+
console: Console,
|
|
789
|
+
ctx: ViewerContext,
|
|
790
|
+
export_callback: Callable[[Path], Path],
|
|
791
|
+
) -> None:
|
|
792
|
+
"""Entry point for creating and running the post-run viewer."""
|
|
793
|
+
viewer = PostRunViewer(console, ctx, export_callback)
|
|
794
|
+
viewer.run()
|