prizmkit 1.1.145 → 1.1.147

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.
@@ -23,7 +23,8 @@ import signal
23
23
  import sys
24
24
  import tempfile
25
25
  import time
26
- from collections import Counter
26
+ from collections import Counter, deque
27
+ from dataclasses import dataclass
27
28
  from datetime import datetime, timezone
28
29
  from pathlib import Path
29
30
 
@@ -198,6 +199,517 @@ def _normalize_status(status, default="unknown"):
198
199
  return text
199
200
 
200
201
 
202
+ @dataclass(frozen=True)
203
+ class SemanticLogEvent:
204
+ """One provider-neutral semantic fragment destined for session.log."""
205
+
206
+ kind: str
207
+ payload: str = ""
208
+ stream_id: str = ""
209
+ mode: str = "complete"
210
+ deduplicate: bool = False
211
+
212
+
213
+ class SemanticLogRenderer:
214
+ """Render semantic fragments without consulting their source format."""
215
+
216
+ LABELS = {
217
+ "think": "[THINK]",
218
+ "action": "[ACTION]",
219
+ "observation": "[OBSERVATION]",
220
+ "error": "[ERROR]",
221
+ "final": "[FINAL]",
222
+ "session_end": "[SESSION_END]",
223
+ "output": "[OUTPUT]",
224
+ }
225
+
226
+ def __init__(self):
227
+ self._active_stream = ""
228
+
229
+ def _close_active(self):
230
+ if not self._active_stream:
231
+ return ""
232
+ self._active_stream = ""
233
+ return "\n"
234
+
235
+ def render(self, event):
236
+ label = self.LABELS.get(event.kind, "[OUTPUT]")
237
+ payload = str(event.payload or "")
238
+ mode = event.mode or "complete"
239
+ stream_id = event.stream_id or f"{event.kind}:anonymous"
240
+
241
+ if mode == "complete":
242
+ prefix = self._close_active()
243
+ suffix = "" if payload.endswith("\n") else "\n"
244
+ spacer = " " if payload else ""
245
+ return f"{prefix}{label}{spacer}{payload}{suffix}"
246
+
247
+ if mode == "start":
248
+ prefix = self._close_active()
249
+ self._active_stream = stream_id
250
+ return f"{prefix}{label} {payload}"
251
+
252
+ if mode == "delta":
253
+ if self._active_stream == stream_id:
254
+ return payload
255
+ prefix = self._close_active()
256
+ self._active_stream = stream_id
257
+ return f"{prefix}{label} {payload}"
258
+
259
+ if mode == "end":
260
+ if self._active_stream == stream_id:
261
+ self._active_stream = ""
262
+ return f"{payload}\n"
263
+ if not payload:
264
+ return ""
265
+ prefix = self._close_active()
266
+ suffix = "" if payload.endswith("\n") else "\n"
267
+ return f"{prefix}{label} {payload}{suffix}"
268
+
269
+ return self.render(SemanticLogEvent(event.kind, payload, stream_id, "complete"))
270
+
271
+ def finish(self):
272
+ return self._close_active()
273
+
274
+
275
+ class SemanticLogNormalizer:
276
+ """Adapt supported structured shapes into one compact semantic log stream."""
277
+
278
+ _SUCCESS_STOPS = {"stop", "end_turn", "completed", "success"}
279
+ _ERROR_STOPS = {"error", "aborted", "failed", "cancelled", "canceled"}
280
+
281
+ def __init__(self):
282
+ self.renderer = SemanticLogRenderer()
283
+ self._scope = 0
284
+ self._block_streams = {}
285
+ self._stream_buffers = {}
286
+ self._snapshots = {}
287
+ self._recent_digests = deque()
288
+ self._recent_digest_set = set()
289
+ self._recent_action_ids = deque()
290
+ self._recent_action_id_set = set()
291
+ self._recent_output_ids = deque()
292
+ self._recent_output_id_set = set()
293
+ self._max_recent = 256
294
+ self._terminal_emitted = False
295
+
296
+ @staticmethod
297
+ def _json_text(value):
298
+ if isinstance(value, str):
299
+ return value
300
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
301
+
302
+ @classmethod
303
+ def _value_text(cls, value):
304
+ if value in (None, ""):
305
+ return ""
306
+ if isinstance(value, str):
307
+ return value
308
+ if isinstance(value, list):
309
+ return "".join(cls._value_text(item) for item in value)
310
+ if isinstance(value, dict):
311
+ for key in ("text", "output", "content"):
312
+ if key in value:
313
+ return cls._value_text(value.get(key))
314
+ return cls._json_text(value)
315
+ return str(value)
316
+
317
+ def _digest(self, kind, payload):
318
+ canonical_payload = str(payload or "").rstrip()
319
+ return hashlib.sha1(f"{self._scope}\0{kind}\0{canonical_payload}".encode("utf-8")).hexdigest()
320
+
321
+ def _remember_digest(self, kind, payload):
322
+ if not payload:
323
+ return
324
+ digest = self._digest(kind, payload)
325
+ if digest in self._recent_digest_set:
326
+ return
327
+ if len(self._recent_digests) >= self._max_recent:
328
+ old = self._recent_digests.popleft()
329
+ self._recent_digest_set.discard(old)
330
+ self._recent_digests.append(digest)
331
+ self._recent_digest_set.add(digest)
332
+
333
+ def _is_recent(self, kind, payload):
334
+ return bool(payload) and self._digest(kind, payload) in self._recent_digest_set
335
+
336
+ def _remember_action_id(self, action_id):
337
+ action_id = str(action_id or "")
338
+ if not action_id or action_id in self._recent_action_id_set:
339
+ return
340
+ if len(self._recent_action_ids) >= self._max_recent:
341
+ old = self._recent_action_ids.popleft()
342
+ self._recent_action_id_set.discard(old)
343
+ self._recent_action_ids.append(action_id)
344
+ self._recent_action_id_set.add(action_id)
345
+
346
+ def _remember_output_id(self, output_id):
347
+ output_id = str(output_id or "")
348
+ if not output_id or output_id in self._recent_output_id_set:
349
+ return
350
+ if len(self._recent_output_ids) >= self._max_recent:
351
+ old = self._recent_output_ids.popleft()
352
+ self._recent_output_id_set.discard(old)
353
+ self._recent_output_ids.append(output_id)
354
+ self._recent_output_id_set.add(output_id)
355
+
356
+ def _emit(self, event):
357
+ payload = str(event.payload or "")
358
+ if event.mode == "complete":
359
+ if event.deduplicate and self._is_recent(event.kind, payload):
360
+ return ""
361
+ if event.deduplicate:
362
+ self._remember_digest(event.kind, payload)
363
+ return self.renderer.render(event)
364
+
365
+ stream_id = event.stream_id or f"{event.kind}:anonymous"
366
+ if event.mode == "start":
367
+ self._stream_buffers[stream_id] = payload
368
+ elif event.mode == "delta":
369
+ self._stream_buffers[stream_id] = self._stream_buffers.get(stream_id, "") + payload
370
+ elif event.mode == "end":
371
+ complete = self._stream_buffers.pop(stream_id, "") + payload
372
+ self._remember_digest(event.kind, complete)
373
+ return self.renderer.render(event)
374
+
375
+ def _complete(self, kind, payload, *, deduplicate=False):
376
+ payload = str(payload or "")
377
+ if not payload:
378
+ return ""
379
+ return self._emit(SemanticLogEvent(kind, payload, deduplicate=deduplicate))
380
+
381
+ def _session_end(self, payload):
382
+ if self._terminal_emitted:
383
+ return ""
384
+ self._terminal_emitted = True
385
+ return self._complete("session_end", payload)
386
+
387
+ def _stream(self, kind, stream_id, mode, payload=""):
388
+ return self._emit(SemanticLogEvent(kind, str(payload or ""), stream_id, mode))
389
+
390
+ def _snapshot(self, kind, stream_id, value, final=False):
391
+ """Render cumulative snapshots while retaining only one active full value."""
392
+ value = str(value or "")
393
+ previous = self._snapshots.get(stream_id)
394
+ rendered = ""
395
+ if previous is None:
396
+ self._snapshots[stream_id] = value
397
+ if value:
398
+ rendered += self.renderer.render(SemanticLogEvent(kind, value, stream_id, "start"))
399
+ elif value == previous:
400
+ pass
401
+ elif value.startswith(previous):
402
+ self._snapshots[stream_id] = value
403
+ rendered += self.renderer.render(
404
+ SemanticLogEvent(kind, value[len(previous):], stream_id, "delta")
405
+ )
406
+ else:
407
+ rendered += self.renderer.render(SemanticLogEvent(kind, "", stream_id, "end"))
408
+ self._snapshots[stream_id] = value
409
+ if value:
410
+ rendered += self.renderer.render(SemanticLogEvent(kind, value, stream_id, "start"))
411
+ if final:
412
+ rendered += self.renderer.render(SemanticLogEvent(kind, "", stream_id, "end"))
413
+ self._snapshots.pop(stream_id, None)
414
+ return rendered
415
+
416
+ def _message_parts(self, message):
417
+ if not isinstance(message, dict):
418
+ return ""
419
+ rendered = ""
420
+ content = message.get("content")
421
+ if isinstance(content, str):
422
+ rendered += self._complete("final", content)
423
+ elif isinstance(content, list):
424
+ for block in content:
425
+ if not isinstance(block, dict):
426
+ continue
427
+ block_type = str(block.get("type") or "")
428
+ if block_type in {"text", "output_text"}:
429
+ rendered += self._complete(
430
+ "final",
431
+ block.get("text") or block.get("content") or "",
432
+ deduplicate=True,
433
+ )
434
+ elif block_type in {"thinking", "reasoning"}:
435
+ rendered += self._complete(
436
+ "think",
437
+ block.get("thinking") or block.get("text") or "",
438
+ deduplicate=True,
439
+ )
440
+ elif block_type == "tool_use":
441
+ action_id = block.get("id") or block.get("tool_use_id")
442
+ self._remember_action_id(action_id)
443
+ rendered += self._complete(
444
+ "action",
445
+ f"{block.get('name') or 'tool'} {self._json_text(block.get('input') or {})}",
446
+ )
447
+ elif block_type == "tool_result":
448
+ rendered += self._complete("observation", self._value_text(block.get("content")))
449
+ return rendered
450
+
451
+ def _tool_result_parts(self, message):
452
+ if not isinstance(message, dict):
453
+ return ""
454
+ rendered = ""
455
+ content = message.get("content")
456
+ if not isinstance(content, list):
457
+ return rendered
458
+ for block in content:
459
+ if isinstance(block, dict) and block.get("type") == "tool_result":
460
+ rendered += self._complete("observation", self._value_text(block.get("content")))
461
+ return rendered
462
+
463
+ def _pi_message_update(self, event):
464
+ update = event.get("assistantMessageEvent")
465
+ if not isinstance(update, dict):
466
+ return ""
467
+ subtype = str(update.get("type") or "")
468
+ index = update.get("contentIndex", 0)
469
+ stream_id = f"turn:{self._scope}:content:{index}"
470
+ if subtype == "thinking_start":
471
+ return self._stream("think", f"{stream_id}:think", "start")
472
+ if subtype == "thinking_delta":
473
+ return self._stream("think", f"{stream_id}:think", "delta", update.get("delta"))
474
+ if subtype == "thinking_end":
475
+ return self._stream("think", f"{stream_id}:think", "end")
476
+ if subtype == "text_start":
477
+ return self._stream("final", f"{stream_id}:final", "start")
478
+ if subtype == "text_delta":
479
+ return self._stream("final", f"{stream_id}:final", "delta", update.get("delta"))
480
+ if subtype == "text_end":
481
+ return self._stream("final", f"{stream_id}:final", "end")
482
+ if subtype == "toolcall_start":
483
+ partial = update.get("partial")
484
+ blocks = partial.get("content") if isinstance(partial, dict) else []
485
+ block = blocks[index] if isinstance(blocks, list) and isinstance(index, int) and index < len(blocks) else {}
486
+ action_id = block.get("id") if isinstance(block, dict) else ""
487
+ self._remember_action_id(action_id)
488
+ prefix = f"{block.get('name') or 'tool'} " if isinstance(block, dict) else "tool "
489
+ return self._stream("action", f"{stream_id}:action", "start", prefix)
490
+ if subtype == "toolcall_delta":
491
+ return self._stream("action", f"{stream_id}:action", "delta", update.get("delta"))
492
+ if subtype == "toolcall_end":
493
+ tool_call = update.get("toolCall")
494
+ if isinstance(tool_call, dict):
495
+ self._remember_action_id(tool_call.get("id"))
496
+ return self._stream("action", f"{stream_id}:action", "end")
497
+ return ""
498
+
499
+ def _content_block_event(self, event):
500
+ event_type = str(event.get("type") or "")
501
+ index = event.get("index", 0)
502
+ stream_id = f"block:{self._scope}:{index}"
503
+ if event_type == "content_block_start":
504
+ block = event.get("content_block") if isinstance(event.get("content_block"), dict) else {}
505
+ block_type = str(block.get("type") or "")
506
+ if block_type in {"thinking", "reasoning"}:
507
+ self._block_streams[index] = ("think", stream_id)
508
+ return self._stream("think", stream_id, "start", block.get("thinking") or "")
509
+ if block_type == "text":
510
+ self._block_streams[index] = ("final", stream_id)
511
+ return self._stream("final", stream_id, "start", block.get("text") or "")
512
+ if block_type == "tool_use":
513
+ self._block_streams[index] = ("action", stream_id)
514
+ self._remember_action_id(block.get("id"))
515
+ return self._stream("action", stream_id, "start", f"{block.get('name') or 'tool'} ")
516
+ return ""
517
+ if event_type == "content_block_delta":
518
+ delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
519
+ delta_type = str(delta.get("type") or "")
520
+ if delta_type in {"thinking_delta", "reasoning_delta"}:
521
+ kind = "think"
522
+ payload = delta.get("thinking") or delta.get("text") or ""
523
+ elif delta_type == "text_delta":
524
+ kind = "final"
525
+ payload = delta.get("text") or ""
526
+ elif delta_type == "input_json_delta":
527
+ kind = "action"
528
+ payload = delta.get("partial_json") or ""
529
+ else:
530
+ return ""
531
+ active_kind, active_id = self._block_streams.get(index, (kind, stream_id))
532
+ return self._stream(active_kind, active_id, "delta", payload)
533
+ if event_type == "content_block_stop":
534
+ kind, active_id = self._block_streams.pop(index, ("output", stream_id))
535
+ return self._stream(kind, active_id, "end")
536
+ return ""
537
+
538
+ def process_text(self, text):
539
+ text = str(text or "")
540
+ return self._complete("output", text.rstrip("\n")) if text.strip() else ""
541
+
542
+ def process_event(self, event):
543
+ if not isinstance(event, dict):
544
+ return self.process_text(event)
545
+ event_type = str(event.get("type") or "")
546
+ rendered = ""
547
+
548
+ if event_type == "turn_start" or event_type == "turn.started":
549
+ self._scope += 1
550
+
551
+ if event_type == "message_update" and isinstance(event.get("assistantMessageEvent"), dict):
552
+ return self._pi_message_update(event)
553
+
554
+ if event_type == "message_end":
555
+ message = event.get("message")
556
+ if isinstance(message, dict):
557
+ role = str(message.get("role") or "")
558
+ if role == "assistant":
559
+ rendered += self._message_parts(message)
560
+ elif role == "toolResult":
561
+ output_id = str(message.get("toolCallId") or "")
562
+ if output_id not in self._recent_output_id_set:
563
+ self._remember_output_id(output_id)
564
+ rendered += self._complete("observation", self._value_text(message.get("content")))
565
+ stop_reason = str(message.get("stopReason") or "")
566
+ if stop_reason in self._ERROR_STOPS:
567
+ rendered += self._complete("error", message.get("errorMessage") or stop_reason)
568
+ return rendered
569
+
570
+ if event_type in {"turn_end", "agent_settled", "agent_start", "session", "thread.started", "turn.started"}:
571
+ return ""
572
+
573
+ if event_type == "tool_execution_start":
574
+ action_id = str(event.get("toolCallId") or event.get("id") or "")
575
+ if action_id in self._recent_action_id_set:
576
+ return ""
577
+ self._remember_action_id(action_id)
578
+ return self._complete(
579
+ "action",
580
+ f"{event.get('toolName') or 'tool'} {self._json_text(event.get('args') or {})}",
581
+ )
582
+
583
+ if event_type == "tool_execution_update":
584
+ stream_id = f"tool:{event.get('toolCallId') or event.get('id') or 'active'}:output"
585
+ return self._snapshot("observation", stream_id, self._value_text(event.get("partialResult")))
586
+
587
+ if event_type == "tool_execution_end":
588
+ output_id = str(event.get("toolCallId") or event.get("id") or "")
589
+ self._remember_output_id(output_id)
590
+ stream_id = f"tool:{output_id or 'active'}:output"
591
+ result_text = self._value_text(event.get("result"))
592
+ had_output = stream_id in self._snapshots and bool(self._snapshots.get(stream_id))
593
+ rendered += self._snapshot("observation", stream_id, result_text, final=True)
594
+ if event.get("isError"):
595
+ error_payload = f"{event.get('toolName') or 'tool'} failed"
596
+ if not had_output and result_text:
597
+ error_payload = f"{error_payload}: {result_text}"
598
+ rendered += self._complete("error", error_payload)
599
+ return rendered
600
+
601
+ if event_type == "auto_retry_start":
602
+ message = event.get("errorMessage") or event.get("finalError") or "retrying transient error"
603
+ return self._complete("error", f"retry attempt {event.get('attempt') or '?'}: {message}")
604
+ if event_type in {"auto_retry_end", "queue_update"}:
605
+ return ""
606
+ if event_type == "compaction_start":
607
+ return self._complete("action", "context compaction started")
608
+ if event_type == "compaction_end":
609
+ if event.get("errorMessage") or event.get("finalError"):
610
+ return self._complete("error", event.get("errorMessage") or event.get("finalError"))
611
+ return self._complete("observation", "context compaction completed")
612
+
613
+ if event_type == "agent_end":
614
+ messages = event.get("messages") if isinstance(event.get("messages"), list) else []
615
+ last_message = messages[-1] if messages and isinstance(messages[-1], dict) else {}
616
+ stop_reason = str(last_message.get("stopReason") or "")
617
+ will_retry = bool(event.get("willRetry"))
618
+ if will_retry:
619
+ return ""
620
+ status = "error" if stop_reason in self._ERROR_STOPS else "success"
621
+ details = f"status={status}"
622
+ if stop_reason:
623
+ details += f" stop_reason={stop_reason}"
624
+ return self._session_end(details)
625
+
626
+ if event_type in {"content_block_start", "content_block_delta", "content_block_stop"}:
627
+ return self._content_block_event(event)
628
+ if event_type == "message_start":
629
+ self._scope += 1
630
+ return ""
631
+ if event_type == "message_stop":
632
+ return self.renderer.finish()
633
+
634
+ if event_type == "assistant":
635
+ self._scope += 1
636
+ return self._message_parts(event.get("message"))
637
+ if event_type == "message" and isinstance(event.get("sessionId"), str):
638
+ if event.get("role") == "assistant":
639
+ self._scope += 1
640
+ return self._message_parts(event)
641
+ return ""
642
+ if event_type == "tool_result":
643
+ return self._complete("observation", self._value_text(event.get("content")))
644
+ if event_type == "user" and not isinstance(event.get("sessionId"), str):
645
+ return self._tool_result_parts(event.get("message"))
646
+ if event_type == "function_call":
647
+ self._remember_action_id(event.get("id") or event.get("call_id"))
648
+ return self._complete(
649
+ "action",
650
+ f"{event.get('name') or 'tool'} {self._value_text(event.get('arguments'))}",
651
+ )
652
+ if event_type == "function_call_result":
653
+ return self._complete("observation", self._value_text(event.get("output") or event.get("result")))
654
+
655
+ if event_type in {"item.started", "item.completed"}:
656
+ item = event.get("item") if isinstance(event.get("item"), dict) else {}
657
+ item_type = str(item.get("type") or "")
658
+ item_id = str(item.get("id") or item.get("call_id") or _stable_subagent_digest(item))
659
+ if item_type in {"reasoning", "analysis"} and event_type == "item.completed":
660
+ return self._complete("think", self._value_text(item.get("text") or item.get("summary") or ""))
661
+ if item_type == "agent_message" and event_type == "item.completed":
662
+ return self._complete("final", item.get("text") or "")
663
+ if item_type in {"command_execution", "mcp_tool_call", "collab_tool_call"}:
664
+ command = item.get("command") or item.get("tool") or item.get("name") or item_type
665
+ if event_type == "item.started":
666
+ self._remember_action_id(item_id)
667
+ return self._complete("action", self._value_text(command))
668
+ if item_id not in self._recent_action_id_set:
669
+ self._remember_action_id(item_id)
670
+ rendered += self._complete("action", self._value_text(command))
671
+ output = item.get("aggregated_output") or item.get("output") or item.get("result")
672
+ rendered += self._complete("observation", self._value_text(output))
673
+ if str(item.get("status") or "") in self._ERROR_STOPS:
674
+ rendered += self._complete("error", self._value_text(item.get("error") or "command failed"))
675
+ return rendered
676
+ if event_type == "item.completed" and item_type in {"file_change", "web_search"}:
677
+ return self._complete("action", self._value_text(item))
678
+ return ""
679
+
680
+ if event_type == "turn.completed":
681
+ return self._session_end("status=success")
682
+ if event_type == "turn.failed":
683
+ error_text = self._value_text(event.get("error") or event.get("message") or "turn failed")
684
+ return self._complete("error", error_text) + self._session_end("status=error")
685
+ if event_type == "result":
686
+ subtype = str(event.get("subtype") or event.get("result_subtype") or "")
687
+ normalized_subtype = subtype.lower()
688
+ is_error = bool(event.get("is_error")) or normalized_subtype in self._ERROR_STOPS or normalized_subtype.startswith(("error", "failed", "cancel"))
689
+ result_text = self._value_text(event.get("result") or event.get("message"))
690
+ if result_text:
691
+ rendered += self._complete(
692
+ "error" if is_error else "final",
693
+ result_text,
694
+ deduplicate=not is_error,
695
+ )
696
+ rendered += self._session_end(
697
+ f"status={'error' if is_error else 'success'}" + (f" stop_reason={event.get('stop_reason')}" if event.get("stop_reason") else ""),
698
+ )
699
+ return rendered
700
+ if event_type == "error" or event.get("error"):
701
+ return self._complete("error", self._value_text(event.get("error") or event.get("message") or "runtime error"))
702
+ return ""
703
+
704
+ def finish(self):
705
+ rendered = self.renderer.finish()
706
+ if not self._terminal_emitted:
707
+ rendered += self._session_end("status=stream_closed")
708
+ self._stream_buffers.clear()
709
+ self._snapshots.clear()
710
+ return rendered
711
+
712
+
201
713
  class ProgressTracker:
202
714
  """Tracks progress state from stream-json events."""
203
715
 
@@ -254,6 +766,7 @@ class ProgressTracker:
254
766
  self._in_tool_use = False
255
767
  self._current_tool_input_parts = []
256
768
  self._current_tool_id = ""
769
+ self._pi_retrying_terminal_error = False
257
770
 
258
771
  def process_event(self, event):
259
772
  """Process a single stream-json event and update state.
@@ -294,6 +807,15 @@ class ProgressTracker:
294
807
  self.event_format = "pi-json"
295
808
  if event_type in {"agent_start", "turn_start", "message_start", "message_update", "tool_execution_start", "tool_execution_update", "compaction_start", "auto_retry_start"}:
296
809
  self.is_active = True
810
+ if event_type == "agent_start" and self._pi_retrying_terminal_error:
811
+ self.result_subtype = ""
812
+ self.stop_reason = ""
813
+ self.last_result_is_error = False
814
+ self.api_error_status = None
815
+ self.api_error_code = ""
816
+ self.fatal_error_code = ""
817
+ self.terminal_result_text = ""
818
+ self.terminal_success_at = ""
297
819
  if event_type == "turn_start":
298
820
  self.message_count += 1
299
821
  elif event_type in {"message_update", "message_end"}:
@@ -355,13 +877,42 @@ class ProgressTracker:
355
877
  elif event_type == "agent_end":
356
878
  self.current_tool = None
357
879
  self.is_active = False
358
- if (
880
+ messages = event.get("messages") if isinstance(event.get("messages"), list) else []
881
+ final_message = messages[-1] if messages and isinstance(messages[-1], dict) else {}
882
+ final_stop_reason = str(final_message.get("stopReason") or self.stop_reason or "")
883
+ will_retry = bool(event.get("willRetry"))
884
+ if will_retry:
885
+ self._pi_retrying_terminal_error = bool(
886
+ self.result_subtype == "error"
887
+ or self.last_result_is_error
888
+ or self.fatal_error_code
889
+ )
890
+ elif final_stop_reason not in {"error", "aborted", "failed", "cancelled", "canceled"} and (
359
891
  self.result_subtype != "error"
360
- and not self.last_result_is_error
361
- and not self.fatal_error_code
892
+ or self._pi_retrying_terminal_error
362
893
  ):
363
894
  self.result_subtype = "success"
895
+ self.stop_reason = final_stop_reason or self.stop_reason
896
+ self.last_result_is_error = False
897
+ if self._pi_retrying_terminal_error:
898
+ self.api_error_status = None
899
+ self.api_error_code = ""
900
+ self.fatal_error_code = ""
901
+ final_text = ""
902
+ content = final_message.get("content") if isinstance(final_message, dict) else None
903
+ if isinstance(content, str):
904
+ final_text = content
905
+ elif isinstance(content, list):
906
+ final_text = "\n".join(
907
+ str(item.get("text") or "")
908
+ for item in content
909
+ if isinstance(item, dict) and item.get("type") == "text"
910
+ )
911
+ if final_text.strip():
912
+ self.terminal_result_text = final_text[:1000]
913
+ self.last_text_snippet = final_text.strip()[-120:]
364
914
  self.terminal_success_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
915
+ self._pi_retrying_terminal_error = False
365
916
  return
366
917
 
367
918
  # ── Codex exec --json JSONL format ──────────────────────────
@@ -412,12 +963,23 @@ class ProgressTracker:
412
963
 
413
964
  elif event_type == "turn.completed":
414
965
  self.current_tool = None
966
+ self.is_active = False
967
+ if not self.fatal_error_code:
968
+ self.result_subtype = "success"
969
+ self.stop_reason = "completed"
970
+ self.last_result_is_error = False
971
+ self.terminal_success_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
415
972
 
416
973
  elif event_type == "turn.failed":
417
974
  error = event.get("error") or event.get("message") or "Codex turn failed"
418
975
  self.errors.append(str(error))
419
976
  self._detect_terminal_error(str(error))
977
+ self.result_subtype = "error"
978
+ self.stop_reason = "failed"
979
+ self.last_result_is_error = True
980
+ self.terminal_result_text = str(error)[:1000]
420
981
  self.current_tool = None
982
+ self.is_active = False
421
983
 
422
984
  elif event_type == "error":
423
985
  error = event.get("error") or event.get("message") or "Unknown error"