glaip-sdk 0.0.20__py3-none-any.whl → 0.1.1__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 (65) 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 +4 -12
  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 +6 -20
  25. glaip_sdk/cli/transcript/launcher.py +1 -3
  26. glaip_sdk/cli/transcript/viewer.py +187 -46
  27. glaip_sdk/cli/update_notifier.py +165 -21
  28. glaip_sdk/cli/utils.py +33 -85
  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 +6 -22
  33. glaip_sdk/client/mcps.py +1 -3
  34. glaip_sdk/client/run_rendering.py +121 -24
  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 +1107 -320
  50. glaip_sdk/utils/rendering/renderer/config.py +3 -5
  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 +10 -22
  55. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  56. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  57. glaip_sdk/utils/rendering/steps.py +899 -25
  58. glaip_sdk/utils/resource_refs.py +4 -13
  59. glaip_sdk/utils/serialization.py +14 -46
  60. glaip_sdk/utils/validation.py +4 -4
  61. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.dist-info}/METADATA +12 -1
  62. glaip_sdk-0.1.1.dist-info/RECORD +82 -0
  63. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  64. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.dist-info}/WHEEL +0 -0
  65. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.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
 
@@ -65,11 +65,7 @@ def compute_finished_at(renderer: Any) -> float | None:
65
65
 
66
66
  if started_at is None:
67
67
  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
- )
68
+ started_at = getattr(stream_processor, "streaming_started_at", None) if stream_processor is not None else None
73
69
  if started_at is None or duration is None:
74
70
  return None
75
71
  try:
@@ -78,9 +74,7 @@ def compute_finished_at(renderer: Any) -> float | None:
78
74
  return None
79
75
 
80
76
 
81
- def extract_server_run_id(
82
- meta: dict[str, Any], events: list[dict[str, Any]]
83
- ) -> str | None:
77
+ def extract_server_run_id(meta: dict[str, Any], events: list[dict[str, Any]]) -> str | None:
84
78
  """Derive a server-side run identifier from renderer metadata."""
85
79
  run_id = meta.get("run_id") or meta.get("id")
86
80
  if run_id:
@@ -107,9 +101,7 @@ def _coerce_meta(meta: Any) -> dict[str, Any]:
107
101
  return {"value": coerce_result_text(meta)}
108
102
 
109
103
 
110
- def register_last_transcript(
111
- ctx: Any, payload: TranscriptPayload, store_result: TranscriptStoreResult
112
- ) -> None:
104
+ def register_last_transcript(ctx: Any, payload: TranscriptPayload, store_result: TranscriptStoreResult) -> None:
113
105
  """Persist last-run transcript references onto the Click context."""
114
106
  ctx_obj = getattr(ctx, "obj", None)
115
107
  if not isinstance(ctx_obj, dict):
@@ -213,9 +205,7 @@ def _derive_transcript_meta(
213
205
 
214
206
  stream_processor = getattr(renderer, "stream_processor", None)
215
207
  stream_started_at = (
216
- getattr(stream_processor, "streaming_started_at", None)
217
- if stream_processor is not None
218
- else None
208
+ getattr(stream_processor, "streaming_started_at", None) if stream_processor is not None else None
219
209
  )
220
210
  finished_at = compute_finished_at(renderer)
221
211
  model_name = meta.get("model") or model
@@ -236,16 +226,12 @@ def store_transcript_for_session(
236
226
  if not hasattr(renderer, "get_transcript_events"):
237
227
  return None
238
228
 
239
- events, aggregated_output, final_output = _collect_renderer_outputs(
240
- renderer, final_result
241
- )
229
+ events, aggregated_output, final_output = _collect_renderer_outputs(renderer, final_result)
242
230
 
243
231
  if not (events or aggregated_output or final_output):
244
232
  return None
245
233
 
246
- meta, stream_started_at, finished_at, model_name = _derive_transcript_meta(
247
- renderer, model
248
- )
234
+ meta, stream_started_at, finished_at, model_name = _derive_transcript_meta(renderer, model)
249
235
 
250
236
  payload: TranscriptPayload = build_transcript_payload(
251
237
  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
@@ -27,12 +27,18 @@ except Exception: # pragma: no cover - optional dependency
27
27
  from glaip_sdk.cli.transcript.cache import suggest_filename
28
28
  from glaip_sdk.icons import 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,13 @@ 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
+ tree_text = self._build_tree_summary_text()
359
+ if tree_text is not None:
360
+ body = tree_text
361
+ else:
362
+ panel_content = self._format_steps_summary(self._build_step_summary())
363
+ body = Text(panel_content, style="dim")
364
+ panel = AIPPanel(body, title="Steps", border_style="blue")
381
365
  self.console.print(panel)
382
366
  self.console.print()
383
367
 
@@ -460,6 +444,169 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
460
444
 
461
445
  return [steps[name] for name in order]
462
446
 
447
+ def _build_tree_summary_text(self) -> Text | None:
448
+ """Render hierarchical tree from captured SSE events when available."""
449
+ manager = StepManager()
450
+ processed = False
451
+
452
+ for event in self.ctx.events:
453
+ payload = self._coerce_step_event(event)
454
+ if not payload:
455
+ continue
456
+ try:
457
+ manager.apply_event(payload)
458
+ processed = True
459
+ except ValueError:
460
+ continue
461
+
462
+ if not processed or not manager.order:
463
+ return None
464
+
465
+ lines: list[str] = []
466
+ roots = manager.order
467
+ total_roots = len(roots)
468
+ for index, root_id in enumerate(roots):
469
+ self._render_tree_branch(
470
+ manager=manager,
471
+ step_id=root_id,
472
+ ancestor_state=(),
473
+ is_last=index == total_roots - 1,
474
+ lines=lines,
475
+ )
476
+
477
+ if not lines:
478
+ return None
479
+
480
+ return Text("\n".join(lines), style="dim")
481
+
482
+ def _render_tree_branch(
483
+ self,
484
+ *,
485
+ manager: StepManager,
486
+ step_id: str,
487
+ ancestor_state: tuple[bool, ...],
488
+ is_last: bool,
489
+ lines: list[str],
490
+ ) -> None:
491
+ step = manager.by_id.get(step_id)
492
+ if not step:
493
+ return
494
+
495
+ suppress = self._should_hide_step(step)
496
+ children = manager.children.get(step_id, [])
497
+
498
+ if not suppress:
499
+ branch_state = ancestor_state
500
+ if branch_state:
501
+ branch_state = branch_state + (is_last,)
502
+ lines.append(self._format_tree_line(step, branch_state))
503
+ next_ancestor_state = ancestor_state + (is_last,)
504
+ else:
505
+ next_ancestor_state = ancestor_state
506
+
507
+ if not children:
508
+ return
509
+
510
+ total_children = len(children)
511
+ for idx, child_id in enumerate(children):
512
+ self._render_tree_branch(
513
+ manager=manager,
514
+ step_id=child_id,
515
+ ancestor_state=next_ancestor_state if not suppress else ancestor_state,
516
+ is_last=idx == total_children - 1,
517
+ lines=lines,
518
+ )
519
+
520
+ def _should_hide_step(self, step: Any) -> bool:
521
+ if getattr(step, "parent_id", None) is not None:
522
+ return False
523
+ if getattr(step, "kind", None) == "thinking":
524
+ return True
525
+ if getattr(step, "kind", None) == "agent":
526
+ return True
527
+ name = getattr(step, "name", "") or ""
528
+ return self._looks_like_uuid(name)
529
+
530
+ def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
531
+ metadata = event.get("metadata")
532
+ if not isinstance(metadata, dict):
533
+ return None
534
+ if not isinstance(metadata.get("step_id"), str):
535
+ return None
536
+ return {
537
+ "metadata": metadata,
538
+ "status": event.get("status"),
539
+ "task_state": event.get("task_state"),
540
+ "content": event.get("content"),
541
+ "task_id": event.get("task_id"),
542
+ "context_id": event.get("context_id"),
543
+ }
544
+
545
+ def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
546
+ prefix = build_connector_prefix(branch_state)
547
+ raw_label = normalise_display_label(getattr(step, "display_label", None))
548
+ title, summary = self._split_label(raw_label)
549
+ line = f"{prefix}{title}"
550
+
551
+ if summary:
552
+ line += f" — {self._truncate_summary(summary)}"
553
+
554
+ badge = self._format_duration_badge(step)
555
+ if badge:
556
+ line += f" {badge}"
557
+
558
+ glyph = glyph_for_status(getattr(step, "status_icon", None))
559
+ failure_reason = getattr(step, "failure_reason", None)
560
+ if glyph and glyph != "spinner":
561
+ if failure_reason and glyph == "✗":
562
+ line += f" {glyph} {failure_reason}"
563
+ else:
564
+ line += f" {glyph}"
565
+ elif failure_reason:
566
+ line += f" ✗ {failure_reason}"
567
+
568
+ return line
569
+
570
+ @staticmethod
571
+ def _format_duration_badge(step: Any) -> str | None:
572
+ duration_ms = getattr(step, "duration_ms", None)
573
+ if duration_ms is None:
574
+ return None
575
+ try:
576
+ duration_ms = int(duration_ms)
577
+ except Exception:
578
+ return None
579
+
580
+ if duration_ms <= 0:
581
+ payload = "<1ms"
582
+ elif duration_ms >= 1000:
583
+ payload = f"{duration_ms / 1000:.2f}s"
584
+ else:
585
+ payload = f"{duration_ms}ms"
586
+
587
+ return f"[{payload}]"
588
+
589
+ @staticmethod
590
+ def _split_label(label: str) -> tuple[str, str | None]:
591
+ if " — " in label:
592
+ title, summary = label.split(" — ", 1)
593
+ return title.strip(), summary.strip()
594
+ return label.strip(), None
595
+
596
+ @staticmethod
597
+ def _truncate_summary(summary: str, limit: int = 48) -> str:
598
+ summary = summary.strip()
599
+ if len(summary) <= limit:
600
+ return summary
601
+ return summary[: limit - 1].rstrip() + "…"
602
+
603
+ @staticmethod
604
+ def _looks_like_uuid(value: str) -> bool:
605
+ stripped = value.replace("-", "")
606
+ if len(stripped) not in {32, 36}:
607
+ return False
608
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
609
+
463
610
  @staticmethod
464
611
  def _format_duration_from_ms(value: Any) -> str | None:
465
612
  try:
@@ -560,11 +707,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
560
707
  status = metadata.get("status")
561
708
  event_time = metadata.get("time")
562
709
 
563
- if (
564
- status == "running"
565
- and step.get("started_at") is None
566
- and isinstance(event_time, (int, float))
567
- ):
710
+ if status == "running" and step.get("started_at") is None and isinstance(event_time, (int, float)):
568
711
  try:
569
712
  step["started_at"] = float(event_time)
570
713
  except Exception:
@@ -590,9 +733,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
590
733
  started_at = step.get("started_at")
591
734
  duration_value: float | None = None
592
735
 
593
- if isinstance(event_time, (int, float)) and isinstance(
594
- started_at, (int, float)
595
- ):
736
+ if isinstance(event_time, (int, float)) and isinstance(started_at, (int, float)):
596
737
  try:
597
738
  delta = float(event_time) - float(started_at)
598
739
  if delta >= 0: