glaip-sdk 0.0.20__py3-none-any.whl → 0.1.3__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.
Files changed (66) hide show
  1. glaip_sdk/_version.py +1 -3
  2. glaip_sdk/branding.py +2 -6
  3. glaip_sdk/cli/agent_config.py +2 -6
  4. glaip_sdk/cli/auth.py +11 -30
  5. glaip_sdk/cli/commands/agents.py +64 -107
  6. glaip_sdk/cli/commands/configure.py +12 -36
  7. glaip_sdk/cli/commands/mcps.py +25 -63
  8. glaip_sdk/cli/commands/models.py +2 -4
  9. glaip_sdk/cli/commands/tools.py +22 -35
  10. glaip_sdk/cli/commands/update.py +3 -8
  11. glaip_sdk/cli/config.py +1 -3
  12. glaip_sdk/cli/display.py +10 -13
  13. glaip_sdk/cli/io.py +8 -14
  14. glaip_sdk/cli/main.py +10 -30
  15. glaip_sdk/cli/mcp_validators.py +5 -15
  16. glaip_sdk/cli/pager.py +3 -9
  17. glaip_sdk/cli/parsers/json_input.py +11 -22
  18. glaip_sdk/cli/resolution.py +3 -9
  19. glaip_sdk/cli/rich_helpers.py +1 -3
  20. glaip_sdk/cli/slash/agent_session.py +5 -10
  21. glaip_sdk/cli/slash/prompt.py +3 -10
  22. glaip_sdk/cli/slash/session.py +46 -98
  23. glaip_sdk/cli/transcript/cache.py +6 -19
  24. glaip_sdk/cli/transcript/capture.py +45 -20
  25. glaip_sdk/cli/transcript/launcher.py +1 -3
  26. glaip_sdk/cli/transcript/viewer.py +224 -47
  27. glaip_sdk/cli/update_notifier.py +165 -21
  28. glaip_sdk/cli/utils.py +33 -91
  29. glaip_sdk/cli/validators.py +11 -12
  30. glaip_sdk/client/_agent_payloads.py +10 -30
  31. glaip_sdk/client/agents.py +33 -63
  32. glaip_sdk/client/base.py +77 -35
  33. glaip_sdk/client/mcps.py +1 -3
  34. glaip_sdk/client/run_rendering.py +121 -26
  35. glaip_sdk/client/tools.py +8 -24
  36. glaip_sdk/client/validators.py +20 -48
  37. glaip_sdk/exceptions.py +1 -3
  38. glaip_sdk/icons.py +9 -3
  39. glaip_sdk/models.py +14 -33
  40. glaip_sdk/payload_schemas/agent.py +1 -3
  41. glaip_sdk/utils/agent_config.py +4 -14
  42. glaip_sdk/utils/client_utils.py +7 -21
  43. glaip_sdk/utils/display.py +2 -6
  44. glaip_sdk/utils/general.py +1 -3
  45. glaip_sdk/utils/import_export.py +3 -9
  46. glaip_sdk/utils/rendering/formatting.py +52 -12
  47. glaip_sdk/utils/rendering/models.py +17 -8
  48. glaip_sdk/utils/rendering/renderer/__init__.py +1 -5
  49. glaip_sdk/utils/rendering/renderer/base.py +1181 -328
  50. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  51. glaip_sdk/utils/rendering/renderer/debug.py +4 -14
  52. glaip_sdk/utils/rendering/renderer/panels.py +1 -3
  53. glaip_sdk/utils/rendering/renderer/progress.py +3 -11
  54. glaip_sdk/utils/rendering/renderer/stream.py +9 -42
  55. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  56. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  57. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  58. glaip_sdk/utils/rendering/steps.py +899 -25
  59. glaip_sdk/utils/resource_refs.py +4 -13
  60. glaip_sdk/utils/serialization.py +14 -46
  61. glaip_sdk/utils/validation.py +4 -4
  62. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/METADATA +12 -1
  63. glaip_sdk-0.1.3.dist-info/RECORD +83 -0
  64. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  65. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/WHEEL +0 -0
  66. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/entry_points.txt +0 -0
@@ -124,9 +124,7 @@ def _json_default(value: Any) -> Any:
124
124
  return repr(value)
125
125
 
126
126
 
127
- def _write_manifest(
128
- entries: Iterable[dict[str, Any]], cache_dir: Path | None = None
129
- ) -> None:
127
+ def _write_manifest(entries: Iterable[dict[str, Any]], cache_dir: Path | None = None) -> None:
130
128
  path = manifest_path(cache_dir)
131
129
  with path.open("w", encoding="utf-8") as fh:
132
130
  for entry in entries:
@@ -212,8 +210,7 @@ def latest_manifest_entry(cache_dir: Path | None = None) -> dict[str, Any] | Non
212
210
  return None
213
211
  return max(
214
212
  entries,
215
- key=lambda e: _parse_iso(e.get("created_at"))
216
- or datetime.min.replace(tzinfo=timezone.utc),
213
+ key=lambda e: _parse_iso(e.get("created_at")) or datetime.min.replace(tzinfo=timezone.utc),
217
214
  )
218
215
 
219
216
 
@@ -237,11 +234,7 @@ def export_transcript(
237
234
  ) -> Path:
238
235
  """Copy a cached transcript to the requested destination path."""
239
236
  directory = ensure_cache_dir(cache_dir)
240
- entry = (
241
- resolve_manifest_entry(run_id, directory)
242
- if run_id
243
- else latest_manifest_entry(directory)
244
- )
237
+ entry = resolve_manifest_entry(run_id, directory) if run_id else latest_manifest_entry(directory)
245
238
  if entry is None:
246
239
  raise FileNotFoundError("No cached transcripts available for export.")
247
240
 
@@ -259,9 +252,7 @@ def export_transcript(
259
252
  lines = cache_file.read_text(encoding="utf-8").splitlines()
260
253
  records = [json.loads(line) for line in lines if line.strip()]
261
254
  except json.JSONDecodeError as exc:
262
- raise FileNotFoundError(
263
- f"Cached transcript file is corrupted: {cache_file}"
264
- ) from exc
255
+ raise FileNotFoundError(f"Cached transcript file is corrupted: {cache_file}") from exc
265
256
 
266
257
  with destination.open("w", encoding="utf-8") as fh:
267
258
  for idx, record in enumerate(records):
@@ -276,12 +267,8 @@ def export_transcript(
276
267
  def suggest_filename(entry: dict[str, Any] | None = None) -> str:
277
268
  """Return a friendly filename suggestion for exporting a transcript."""
278
269
  run_id = entry.get("run_id") if entry else uuid.uuid4().hex
279
- created_at = (
280
- entry.get("created_at") if entry else datetime.now(timezone.utc).isoformat()
281
- )
282
- timestamp = (
283
- created_at.replace(":", "").replace("-", "").replace("T", "_").split("+")[0]
284
- )
270
+ created_at = entry.get("created_at") if entry else datetime.now(timezone.utc).isoformat()
271
+ timestamp = created_at.replace(":", "").replace("-", "").replace("T", "_").split("+")[0]
285
272
  return f"aip-run-{timestamp}-{run_id}.jsonl"
286
273
 
287
274
 
@@ -8,8 +8,11 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  from dataclasses import dataclass
11
+ from io import StringIO
11
12
  from typing import Any
12
13
 
14
+ from rich.console import Console
15
+
13
16
  from glaip_sdk.cli.transcript.cache import (
14
17
  TranscriptPayload,
15
18
  TranscriptStoreResult,
@@ -65,11 +68,7 @@ def compute_finished_at(renderer: Any) -> float | None:
65
68
 
66
69
  if started_at is None:
67
70
  stream_processor = getattr(renderer, "stream_processor", None)
68
- started_at = (
69
- getattr(stream_processor, "streaming_started_at", None)
70
- if stream_processor is not None
71
- else None
72
- )
71
+ started_at = getattr(stream_processor, "streaming_started_at", None) if stream_processor is not None else None
73
72
  if started_at is None or duration is None:
74
73
  return None
75
74
  try:
@@ -78,9 +77,7 @@ def compute_finished_at(renderer: Any) -> float | None:
78
77
  return None
79
78
 
80
79
 
81
- def extract_server_run_id(
82
- meta: dict[str, Any], events: list[dict[str, Any]]
83
- ) -> str | None:
80
+ def extract_server_run_id(meta: dict[str, Any], events: list[dict[str, Any]]) -> str | None:
84
81
  """Derive a server-side run identifier from renderer metadata."""
85
82
  run_id = meta.get("run_id") or meta.get("id")
86
83
  if run_id:
@@ -107,9 +104,7 @@ def _coerce_meta(meta: Any) -> dict[str, Any]:
107
104
  return {"value": coerce_result_text(meta)}
108
105
 
109
106
 
110
- def register_last_transcript(
111
- ctx: Any, payload: TranscriptPayload, store_result: TranscriptStoreResult
112
- ) -> None:
107
+ def register_last_transcript(ctx: Any, payload: TranscriptPayload, store_result: TranscriptStoreResult) -> None:
113
108
  """Persist last-run transcript references onto the Click context."""
114
109
  ctx_obj = getattr(ctx, "obj", None)
115
110
  if not isinstance(ctx_obj, dict):
@@ -172,6 +167,38 @@ def _format_step_display_name(name: str) -> str:
172
167
  return name
173
168
 
174
169
 
170
+ def _extract_step_summary_lines(renderer: Any) -> list[str]:
171
+ """Render the live steps summary to plain text lines."""
172
+ if not hasattr(renderer, "_render_steps_text"):
173
+ return []
174
+
175
+ try:
176
+ renderable = renderer._render_steps_text()
177
+ except Exception:
178
+ return []
179
+
180
+ buffer = StringIO()
181
+ console = Console(file=buffer, record=True, force_terminal=False, width=120)
182
+ try:
183
+ console.print(renderable)
184
+ except Exception:
185
+ return []
186
+
187
+ text = console.export_text() or buffer.getvalue()
188
+ lines = [line.rstrip() for line in text.splitlines()]
189
+ half = len(lines) // 2
190
+ if half and lines[:half] == lines[half : half * 2]:
191
+ return lines[:half]
192
+ start = 0
193
+ prefixes = ("🤖", "🔧", "💭", "├", "└", "│", "•")
194
+ for idx, line in enumerate(lines):
195
+ if line.lstrip().startswith(prefixes):
196
+ start = idx
197
+ break
198
+ trimmed = lines[start:]
199
+ return [line for line in trimmed if line]
200
+
201
+
175
202
  def _collect_renderer_outputs(
176
203
  renderer: Any, final_result: Any
177
204
  ) -> tuple[
@@ -211,11 +238,13 @@ def _derive_transcript_meta(
211
238
  if step_summaries:
212
239
  meta["transcript_steps"] = step_summaries
213
240
 
241
+ step_lines = _extract_step_summary_lines(renderer)
242
+ if step_lines:
243
+ meta["transcript_step_lines"] = step_lines
244
+
214
245
  stream_processor = getattr(renderer, "stream_processor", None)
215
246
  stream_started_at = (
216
- getattr(stream_processor, "streaming_started_at", None)
217
- if stream_processor is not None
218
- else None
247
+ getattr(stream_processor, "streaming_started_at", None) if stream_processor is not None else None
219
248
  )
220
249
  finished_at = compute_finished_at(renderer)
221
250
  model_name = meta.get("model") or model
@@ -236,16 +265,12 @@ def store_transcript_for_session(
236
265
  if not hasattr(renderer, "get_transcript_events"):
237
266
  return None
238
267
 
239
- events, aggregated_output, final_output = _collect_renderer_outputs(
240
- renderer, final_result
241
- )
268
+ events, aggregated_output, final_output = _collect_renderer_outputs(renderer, final_result)
242
269
 
243
270
  if not (events or aggregated_output or final_output):
244
271
  return None
245
272
 
246
- meta, stream_started_at, finished_at, model_name = _derive_transcript_meta(
247
- renderer, model
248
- )
273
+ meta, stream_started_at, finished_at, model_name = _derive_transcript_meta(renderer, model)
249
274
 
250
275
  payload: TranscriptPayload = build_transcript_payload(
251
276
  events=events,
@@ -20,9 +20,7 @@ from glaip_sdk.cli.transcript.capture import StoredTranscriptContext
20
20
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
21
21
 
22
22
 
23
- def should_launch_post_run_viewer(
24
- ctx: Any, console: Console, *, slash_mode: bool
25
- ) -> bool:
23
+ def should_launch_post_run_viewer(ctx: Any, console: Console, *, slash_mode: bool) -> bool:
26
24
  """Return True if the viewer should open automatically."""
27
25
  if slash_mode:
28
26
  return False
@@ -25,14 +25,20 @@ except Exception: # pragma: no cover - optional dependency
25
25
  Choice = None # type: ignore[assignment]
26
26
 
27
27
  from glaip_sdk.cli.transcript.cache import suggest_filename
28
- from glaip_sdk.icons import ICON_DELEGATE, ICON_TOOL_STEP
28
+ from glaip_sdk.icons import ICON_AGENT, ICON_DELEGATE, ICON_TOOL_STEP
29
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
+ )
30
35
  from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
31
36
  from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
32
37
  from glaip_sdk.utils.rendering.renderer.progress import (
33
38
  format_elapsed_time,
34
39
  is_delegation_tool,
35
40
  )
41
+ from glaip_sdk.utils.rendering.steps import StepManager
36
42
 
37
43
  EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
38
44
 
@@ -66,9 +72,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
66
72
 
67
73
  def run(self) -> None:
68
74
  """Enter the interactive loop."""
69
- if not self.ctx.events and not (
70
- self.ctx.default_output or self.ctx.final_output
71
- ):
75
+ if not self.ctx.events and not (self.ctx.default_output or self.ctx.final_output):
72
76
  return
73
77
  if self._view_mode == "transcript":
74
78
  self._render()
@@ -85,9 +89,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
85
89
  except Exception: # pragma: no cover - platform quirks
86
90
  pass
87
91
 
88
- header = (
89
- f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
90
- )
92
+ header = f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
91
93
  agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
92
94
  model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
93
95
  agent_id = self.ctx.manifest_entry.get("agent_id")
@@ -128,9 +130,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
128
130
  self._render_final_panel()
129
131
 
130
132
  self.console.print("[bold]Transcript Events[/bold]")
131
- self.console.print(
132
- "[dim]────────────────────────────────────────────────────────[/dim]"
133
- )
133
+ self.console.print("[dim]────────────────────────────────────────────────────────[/dim]")
134
134
 
135
135
  base_received_ts: datetime | None = None
136
136
  for event in self.ctx.events:
@@ -146,11 +146,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
146
146
  self.console.print()
147
147
 
148
148
  def _render_final_panel(self) -> None:
149
- content = (
150
- self.ctx.final_output
151
- or self.ctx.default_output
152
- or "No response content captured."
153
- )
149
+ content = self.ctx.final_output or self.ctx.default_output or "No response content captured."
154
150
  title = "Final Result"
155
151
  duration_text = self._extract_final_duration()
156
152
  if duration_text:
@@ -212,9 +208,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
212
208
  raw = str(path)
213
209
  return raw if len(raw) <= 80 else f"…{raw[-77:]}"
214
210
 
215
- selection = self._prompt_export_choice(
216
- default_path, _display_path(default_path)
217
- )
211
+ selection = self._prompt_export_choice(default_path, _display_path(default_path))
218
212
  if selection is None:
219
213
  self._legacy_export_prompt(default_path, _display_path)
220
214
  return
@@ -240,9 +234,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
240
234
  except Exception as exc: # pragma: no cover - unexpected IO failures
241
235
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
242
236
 
243
- def _prompt_export_choice(
244
- self, default_path: Path, default_display: str
245
- ) -> tuple[str, Any] | None:
237
+ def _prompt_export_choice(self, default_path: Path, default_display: str) -> tuple[str, Any] | None:
246
238
  """Render interactive export menu with numeric shortcuts."""
247
239
  if not self.console.is_terminal or questionary is None or Choice is None:
248
240
  return None
@@ -299,9 +291,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
299
291
  candidate = Path.cwd() / candidate
300
292
  return candidate
301
293
 
302
- def _legacy_export_prompt(
303
- self, default_path: Path, formatter: Callable[[Path], str]
304
- ) -> None:
294
+ def _legacy_export_prompt(self, default_path: Path, formatter: Callable[[Path], str]) -> None:
305
295
  """Fallback export workflow when interactive UI is unavailable."""
306
296
  self.console.print("[dim]Export options (fallback mode)[/dim]")
307
297
  self.console.print(f" 1. Save to default ({formatter(default_path)})")
@@ -347,20 +337,13 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
347
337
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
348
338
 
349
339
  def _print_command_hint(self) -> None:
350
- self.console.print(
351
- "[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]"
352
- )
340
+ self.console.print("[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]")
353
341
  self.console.print()
354
342
 
355
343
  def _get_user_query(self) -> str | None:
356
344
  meta = self.ctx.meta or {}
357
345
  manifest = self.ctx.manifest_entry or {}
358
- return (
359
- meta.get("input_message")
360
- or meta.get("query")
361
- or meta.get("message")
362
- or manifest.get("input_message")
363
- )
346
+ return meta.get("input_message") or meta.get("query") or meta.get("message") or manifest.get("input_message")
364
347
 
365
348
  def _render_user_query(self, query: str) -> None:
366
349
  panel = AIPPanel(
@@ -372,12 +355,17 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
372
355
  self.console.print()
373
356
 
374
357
  def _render_steps_summary(self) -> None:
375
- panel_content = self._format_steps_summary(self._build_step_summary())
376
- panel = AIPPanel(
377
- Text(panel_content, style="dim"),
378
- title="Steps",
379
- border_style="blue",
380
- )
358
+ stored_lines = self.ctx.meta.get("transcript_step_lines")
359
+ if stored_lines:
360
+ body = Text("\n".join(stored_lines), style="dim")
361
+ else:
362
+ tree_text = self._build_tree_summary_text()
363
+ if tree_text is not None:
364
+ body = tree_text
365
+ else:
366
+ panel_content = self._format_steps_summary(self._build_step_summary())
367
+ body = Text(panel_content, style="dim")
368
+ panel = AIPPanel(body, title="Steps", border_style="blue")
381
369
  self.console.print(panel)
382
370
  self.console.print()
383
371
 
@@ -460,6 +448,201 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
460
448
 
461
449
  return [steps[name] for name in order]
462
450
 
451
+ def _build_tree_summary_text(self) -> Text | None:
452
+ """Render hierarchical tree from captured SSE events when available."""
453
+ manager = StepManager()
454
+ processed = False
455
+
456
+ for event in self.ctx.events:
457
+ payload = self._coerce_step_event(event)
458
+ if not payload:
459
+ continue
460
+ try:
461
+ manager.apply_event(payload)
462
+ processed = True
463
+ except ValueError:
464
+ continue
465
+
466
+ if not processed or not manager.order:
467
+ return None
468
+
469
+ lines: list[str] = []
470
+ roots = manager.order
471
+ total_roots = len(roots)
472
+ for index, root_id in enumerate(roots):
473
+ self._render_tree_branch(
474
+ manager=manager,
475
+ step_id=root_id,
476
+ ancestor_state=(),
477
+ is_last=index == total_roots - 1,
478
+ lines=lines,
479
+ )
480
+
481
+ if not lines:
482
+ return None
483
+
484
+ self._decorate_root_presentation(manager, roots[0], lines)
485
+
486
+ return Text("\n".join(lines), style="dim")
487
+
488
+ def _render_tree_branch(
489
+ self,
490
+ *,
491
+ manager: StepManager,
492
+ step_id: str,
493
+ ancestor_state: tuple[bool, ...],
494
+ is_last: bool,
495
+ lines: list[str],
496
+ ) -> None:
497
+ step = manager.by_id.get(step_id)
498
+ if not step:
499
+ return
500
+ suppress = self._should_hide_step(step)
501
+ children = manager.children.get(step_id, [])
502
+
503
+ if not suppress:
504
+ branch_state = ancestor_state
505
+ if branch_state:
506
+ branch_state = branch_state + (is_last,)
507
+ lines.append(self._format_tree_line(step, branch_state))
508
+ next_ancestor_state = ancestor_state + (is_last,)
509
+ else:
510
+ next_ancestor_state = ancestor_state
511
+
512
+ if not children:
513
+ return
514
+
515
+ total_children = len(children)
516
+ for idx, child_id in enumerate(children):
517
+ self._render_tree_branch(
518
+ manager=manager,
519
+ step_id=child_id,
520
+ ancestor_state=next_ancestor_state if not suppress else ancestor_state,
521
+ is_last=idx == total_children - 1,
522
+ lines=lines,
523
+ )
524
+
525
+ def _should_hide_step(self, step: Any) -> bool:
526
+ if getattr(step, "parent_id", None) is None:
527
+ return False
528
+ name = getattr(step, "name", "") or ""
529
+ return self._looks_like_uuid(name)
530
+
531
+ def _decorate_root_presentation(
532
+ self,
533
+ manager: StepManager,
534
+ root_id: str,
535
+ lines: list[str],
536
+ ) -> None:
537
+ if not lines:
538
+ return
539
+
540
+ root_step = manager.by_id.get(root_id)
541
+ if not root_step:
542
+ return
543
+
544
+ original_label = getattr(root_step, "display_label", None)
545
+ root_step.display_label = self._friendly_root_label(root_step, original_label)
546
+ lines[0] = self._format_tree_line(root_step, ())
547
+ if original_label is not None:
548
+ root_step.display_label = original_label
549
+
550
+ query = self._get_user_query()
551
+ if query:
552
+ lines.insert(1, f" {query}")
553
+
554
+ def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
555
+ metadata = event.get("metadata")
556
+ if not isinstance(metadata, dict):
557
+ return None
558
+ if not isinstance(metadata.get("step_id"), str):
559
+ return None
560
+ return {
561
+ "metadata": metadata,
562
+ "status": event.get("status"),
563
+ "task_state": event.get("task_state"),
564
+ "content": event.get("content"),
565
+ "task_id": event.get("task_id"),
566
+ "context_id": event.get("context_id"),
567
+ }
568
+
569
+ def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
570
+ prefix = build_connector_prefix(branch_state)
571
+ raw_label = normalise_display_label(getattr(step, "display_label", None))
572
+ title, summary = self._split_label(raw_label)
573
+ line = f"{prefix}{title}"
574
+
575
+ if summary:
576
+ line += f" — {self._truncate_summary(summary)}"
577
+
578
+ badge = self._format_duration_badge(step)
579
+ if badge:
580
+ line += f" {badge}"
581
+
582
+ glyph = glyph_for_status(getattr(step, "status_icon", None))
583
+ failure_reason = getattr(step, "failure_reason", None)
584
+ if glyph and glyph != "spinner":
585
+ if failure_reason and glyph == "✗":
586
+ line += f" {glyph} {failure_reason}"
587
+ else:
588
+ line += f" {glyph}"
589
+ elif failure_reason:
590
+ line += f" ✗ {failure_reason}"
591
+
592
+ return line
593
+
594
+ def _friendly_root_label(self, step: Any, fallback: str | None) -> str:
595
+ agent_name = self.ctx.manifest_entry.get("agent_name") or (self.ctx.meta or {}).get("agent_name")
596
+ agent_id = self.ctx.manifest_entry.get("agent_id") or getattr(step, "name", "")
597
+
598
+ if not agent_name:
599
+ return fallback or agent_id or ICON_AGENT
600
+
601
+ parts = [ICON_AGENT, agent_name]
602
+ if agent_id and agent_id != agent_name:
603
+ parts.append(f"({agent_id})")
604
+ return " ".join(parts)
605
+
606
+ @staticmethod
607
+ def _format_duration_badge(step: Any) -> str | None:
608
+ duration_ms = getattr(step, "duration_ms", None)
609
+ if duration_ms is None:
610
+ return None
611
+ try:
612
+ duration_ms = int(duration_ms)
613
+ except Exception:
614
+ return None
615
+
616
+ if duration_ms <= 0:
617
+ payload = "<1ms"
618
+ elif duration_ms >= 1000:
619
+ payload = f"{duration_ms / 1000:.2f}s"
620
+ else:
621
+ payload = f"{duration_ms}ms"
622
+
623
+ return f"[{payload}]"
624
+
625
+ @staticmethod
626
+ def _split_label(label: str) -> tuple[str, str | None]:
627
+ if " — " in label:
628
+ title, summary = label.split(" — ", 1)
629
+ return title.strip(), summary.strip()
630
+ return label.strip(), None
631
+
632
+ @staticmethod
633
+ def _truncate_summary(summary: str, limit: int = 48) -> str:
634
+ summary = summary.strip()
635
+ if len(summary) <= limit:
636
+ return summary
637
+ return summary[: limit - 1].rstrip() + "…"
638
+
639
+ @staticmethod
640
+ def _looks_like_uuid(value: str) -> bool:
641
+ stripped = value.replace("-", "").replace(" ", "")
642
+ if len(stripped) not in {32, 36}:
643
+ return False
644
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
645
+
463
646
  @staticmethod
464
647
  def _format_duration_from_ms(value: Any) -> str | None:
465
648
  try:
@@ -560,11 +743,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
560
743
  status = metadata.get("status")
561
744
  event_time = metadata.get("time")
562
745
 
563
- if (
564
- status == "running"
565
- and step.get("started_at") is None
566
- and isinstance(event_time, (int, float))
567
- ):
746
+ if status == "running" and step.get("started_at") is None and isinstance(event_time, (int, float)):
568
747
  try:
569
748
  step["started_at"] = float(event_time)
570
749
  except Exception:
@@ -590,9 +769,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
590
769
  started_at = step.get("started_at")
591
770
  duration_value: float | None = None
592
771
 
593
- if isinstance(event_time, (int, float)) and isinstance(
594
- started_at, (int, float)
595
- ):
772
+ if isinstance(event_time, (int, float)) and isinstance(started_at, (int, float)):
596
773
  try:
597
774
  delta = float(event_time) - float(started_at)
598
775
  if delta >= 0: