prizmkit 1.1.145 → 1.1.146

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.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Capability-gated live smoke checks for normalized AI CLI session logging.
3
+
4
+ The deterministic fixture suite remains authoritative. This script optionally
5
+ exercises locally installed, authenticated, non-interactive-capable adapters in
6
+ isolated temporary Git repositories.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from dataclasses import asdict, dataclass
19
+ from pathlib import Path
20
+ from typing import Callable, Iterable
21
+
22
+ PIPELINE_ROOT = Path(__file__).resolve().parents[1]
23
+ if str(PIPELINE_ROOT) not in sys.path:
24
+ sys.path.insert(0, str(PIPELINE_ROOT))
25
+
26
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher # noqa: E402
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SmokeProfile:
31
+ """One installed adapter example to exercise."""
32
+
33
+ name: str
34
+ command: str
35
+ platform: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SmokeResult:
40
+ """Machine-readable capability or execution result."""
41
+
42
+ name: str
43
+ status: str
44
+ reason: str
45
+ log_path: str = ""
46
+ exit_code: int | None = None
47
+
48
+
49
+ PROFILE_EXAMPLES = (
50
+ ("pi", "pi", "pi"),
51
+ ("codebuddy", "cbc", "codebuddy"),
52
+ ("codex", "codex", "codex"),
53
+ ("claude", "claude", "claude"),
54
+ )
55
+
56
+ AUTH_MARKERS = (
57
+ "authentication",
58
+ "unauthorized",
59
+ "not logged in",
60
+ "login required",
61
+ "invalid api key",
62
+ "missing api key",
63
+ "credential",
64
+ "billing",
65
+ "quota",
66
+ "model access",
67
+ "model not found",
68
+ "no access to model",
69
+ "network is unreachable",
70
+ "connection refused",
71
+ "could not resolve",
72
+ "service unavailable",
73
+ )
74
+
75
+ REQUIRED_LABELS = (
76
+ "[ACTION]",
77
+ "[OBSERVATION]",
78
+ "[FINAL]",
79
+ "[SESSION_END]",
80
+ )
81
+
82
+
83
+ def discover_profiles(
84
+ which: Callable[[str], str | None] = shutil.which,
85
+ examples: Iterable[tuple[str, str, str]] = PROFILE_EXAMPLES,
86
+ ) -> tuple[list[SmokeProfile], list[SmokeResult]]:
87
+ """Return installed profiles and explicit executable-capability skips."""
88
+ installed = []
89
+ skipped = []
90
+ for name, executable, platform in examples:
91
+ resolved = which(executable)
92
+ if resolved:
93
+ installed.append(SmokeProfile(name, resolved, platform))
94
+ else:
95
+ skipped.append(SmokeResult(name, "skipped", f"executable not found: {executable}"))
96
+ return installed, skipped
97
+
98
+
99
+ def smoke_prompt() -> str:
100
+ """Return the isolated harmless tool-and-final-answer prompt."""
101
+ return (
102
+ "This is an isolated logging smoke test. Use one local shell tool to run "
103
+ "`printf 'PRIZMKIT_SMOKE_TOOL_OK\\n'`. Do not read, create, edit, or delete "
104
+ "project files. After the tool succeeds, answer exactly "
105
+ "`PRIZMKIT_SMOKE_FINAL_OK`."
106
+ )
107
+
108
+
109
+ def _looks_capability_blocked(text: str) -> bool:
110
+ lowered = text.lower()
111
+ return any(marker in lowered for marker in AUTH_MARKERS)
112
+
113
+
114
+ def run_profile(profile: SmokeProfile, *, timeout_seconds: float = 180) -> SmokeResult:
115
+ """Run one live adapter in an isolated temporary repository."""
116
+ with tempfile.TemporaryDirectory(prefix=f"prizmkit-{profile.name}-smoke-") as temp_dir:
117
+ root = Path(temp_dir)
118
+ subprocess.run(
119
+ ["git", "init", "-b", "main"],
120
+ cwd=root,
121
+ check=True,
122
+ stdout=subprocess.DEVNULL,
123
+ stderr=subprocess.DEVNULL,
124
+ )
125
+ prompt = root / "prompt.md"
126
+ prompt.write_text(smoke_prompt(), encoding="utf-8")
127
+ session_log = root / ".prizmkit" / "state" / "smoke" / "logs" / "session.log"
128
+ progress = session_log.with_name("progress.json")
129
+ result = AISessionLauncher(
130
+ AISessionConfig(
131
+ cli_command=profile.command,
132
+ platform=profile.platform,
133
+ model=None,
134
+ prompt_path=prompt,
135
+ cwd=root,
136
+ log_path=session_log,
137
+ progress_path=progress,
138
+ use_stream_json=True,
139
+ effort="low",
140
+ total_timeout_seconds=timeout_seconds,
141
+ )
142
+ ).run()
143
+ text = session_log.read_text(encoding="utf-8", errors="replace") if session_log.is_file() else ""
144
+ missing = [label for label in REQUIRED_LABELS if label not in text]
145
+ if _looks_capability_blocked(text):
146
+ return SmokeResult(
147
+ profile.name,
148
+ "skipped",
149
+ "authentication, model, quota, network, or account capability unavailable",
150
+ str(session_log),
151
+ result.exit_code,
152
+ )
153
+ if result.timed_out:
154
+ return SmokeResult(profile.name, "failed", "live smoke timed out without a capability-block marker", str(session_log), result.exit_code)
155
+ if result.exit_code not in (0, None):
156
+ return SmokeResult(profile.name, "failed", f"AI CLI exited with {result.exit_code}", str(session_log), result.exit_code)
157
+ if missing:
158
+ return SmokeResult(
159
+ profile.name,
160
+ "failed",
161
+ "missing normalized labels: " + ", ".join(missing),
162
+ str(session_log),
163
+ result.exit_code,
164
+ )
165
+ if "PRIZMKIT_SMOKE_TOOL_OK" not in text or "PRIZMKIT_SMOKE_FINAL_OK" not in text:
166
+ return SmokeResult(
167
+ profile.name,
168
+ "failed",
169
+ "tool or final-answer sentinel missing",
170
+ str(session_log),
171
+ result.exit_code,
172
+ )
173
+ return SmokeResult(profile.name, "passed", "normalized live tool session verified", str(session_log), result.exit_code)
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ parser = argparse.ArgumentParser(description=__doc__)
178
+ parser.add_argument("--run", action="store_true", help="authorize live AI CLI calls")
179
+ parser.add_argument("--only", action="append", default=[], help="run only a named installed example")
180
+ parser.add_argument("--timeout", type=float, default=180, help="per-profile timeout in seconds")
181
+ args = parser.parse_args(argv)
182
+
183
+ installed, results = discover_profiles()
184
+ selected = set(args.only)
185
+ if selected:
186
+ installed = [profile for profile in installed if profile.name in selected]
187
+ known = {profile.name for profile in installed} | {result.name for result in results}
188
+ for name in sorted(selected - known):
189
+ results.append(SmokeResult(name, "skipped", "unknown or unavailable profile example"))
190
+
191
+ authorized = args.run or os.environ.get("PRIZMKIT_RUN_LIVE_AI_SMOKE") == "1"
192
+ if not authorized:
193
+ results.extend(
194
+ SmokeResult(profile.name, "skipped", "live calls not authorized; pass --run or set PRIZMKIT_RUN_LIVE_AI_SMOKE=1")
195
+ for profile in installed
196
+ )
197
+ else:
198
+ results.extend(run_profile(profile, timeout_seconds=args.timeout) for profile in installed)
199
+
200
+ payload = {"results": [asdict(result) for result in sorted(results, key=lambda item: item.name)]}
201
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
202
+ return 1 if any(result.status == "failed" for result in results) else 0
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
@@ -2403,7 +2403,133 @@ def test_preflight_ready_commit_includes_tracked_state_but_excludes_untracked_st
2403
2403
  assert untracked_state.relative_to(tmp_path).as_posix() not in _head_files(tmp_path)
2404
2404
 
2405
2405
 
2406
- def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserves_other_tracked_support(tmp_path):
2406
+ def _track_manifest(project_root):
2407
+ manifest_path = project_root / ".prizmkit" / "manifest.json"
2408
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
2409
+ manifest_path.write_text('{"version":"baseline"}\n', encoding="utf-8")
2410
+ subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=project_root, check=True)
2411
+ subprocess.run(["git", "commit", "-m", "track manifest"], cwd=project_root, check=True, stdout=subprocess.DEVNULL)
2412
+ return manifest_path
2413
+
2414
+
2415
+ @pytest.mark.parametrize(
2416
+ ("scenario", "expected_content"),
2417
+ [
2418
+ ("staged_only", '{"version":"staged"}\n'),
2419
+ ("unstaged_only", '{"version":"unstaged"}\n'),
2420
+ ("staged_and_unstaged", '{"version":"working-tree"}\n'),
2421
+ ],
2422
+ )
2423
+ def test_preflight_ready_commit_preserves_tracked_manifest_modification_state(tmp_path, scenario, expected_content):
2424
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2425
+
2426
+ _init_bookkeeping_repo(tmp_path)
2427
+ manifest_path = _track_manifest(tmp_path)
2428
+ if scenario == "staged_only":
2429
+ manifest_path.write_text(expected_content, encoding="utf-8")
2430
+ subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
2431
+ elif scenario == "unstaged_only":
2432
+ manifest_path.write_text(expected_content, encoding="utf-8")
2433
+ else:
2434
+ manifest_path.write_text('{"version":"index"}\n', encoding="utf-8")
2435
+ subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
2436
+ manifest_path.write_text(expected_content, encoding="utf-8")
2437
+
2438
+ before_count = _head_commit_count(tmp_path)
2439
+ result = commit_preflight_ready_changes(tmp_path, "F-003")
2440
+ committed_content = subprocess.run(
2441
+ ["git", "show", "HEAD:.prizmkit/manifest.json"],
2442
+ cwd=tmp_path,
2443
+ check=True,
2444
+ stdout=subprocess.PIPE,
2445
+ text=True,
2446
+ ).stdout
2447
+
2448
+ assert result.attempted is True
2449
+ assert result.committed is True
2450
+ assert _head_commit_count(tmp_path) == before_count + 1
2451
+ assert _head_message(tmp_path) == "chore: ready for F-003"
2452
+ assert _head_files(tmp_path) == {".prizmkit/manifest.json"}
2453
+ assert committed_content == expected_content
2454
+ assert subprocess.run(
2455
+ ["git", "status", "--short", "--untracked-files=no"],
2456
+ cwd=tmp_path,
2457
+ check=True,
2458
+ stdout=subprocess.PIPE,
2459
+ text=True,
2460
+ ).stdout == ""
2461
+
2462
+
2463
+ @pytest.mark.parametrize("staged", [False, True])
2464
+ def test_preflight_ready_commit_preserves_tracked_manifest_deletion(tmp_path, staged):
2465
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2466
+
2467
+ _init_bookkeeping_repo(tmp_path)
2468
+ manifest_path = _track_manifest(tmp_path)
2469
+ manifest_path.unlink()
2470
+ if staged:
2471
+ subprocess.run(["git", "add", "-u", "--", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
2472
+ (tmp_path / "unstaged.txt").write_text("coexisting tracked change\n", encoding="utf-8")
2473
+ (tmp_path / "coexisting.txt").write_text("coexisting untracked change\n", encoding="utf-8")
2474
+
2475
+ before_count = _head_commit_count(tmp_path)
2476
+ result = commit_preflight_ready_changes(tmp_path, "B-003")
2477
+ manifest_at_head = subprocess.run(
2478
+ ["git", "cat-file", "-e", "HEAD:.prizmkit/manifest.json"],
2479
+ cwd=tmp_path,
2480
+ stdout=subprocess.DEVNULL,
2481
+ stderr=subprocess.DEVNULL,
2482
+ )
2483
+
2484
+ assert result.attempted is True
2485
+ assert result.committed is True
2486
+ assert _head_commit_count(tmp_path) == before_count + 1
2487
+ assert _head_message(tmp_path) == "chore: ready for B-003"
2488
+ assert _head_files(tmp_path) == {".prizmkit/manifest.json", "coexisting.txt", "unstaged.txt"}
2489
+ assert manifest_at_head.returncode != 0
2490
+ assert subprocess.run(
2491
+ ["git", "status", "--short", "--untracked-files=all"],
2492
+ cwd=tmp_path,
2493
+ check=True,
2494
+ stdout=subprocess.PIPE,
2495
+ text=True,
2496
+ ).stdout == ""
2497
+
2498
+
2499
+ @pytest.mark.parametrize(
2500
+ ("payload", "expected_error"),
2501
+ [
2502
+ ("{not-json\n", "not valid JSON"),
2503
+ ("[]\n", "not a JSON object"),
2504
+ ],
2505
+ )
2506
+ def test_preflight_ready_commit_rejects_unsafe_tracked_manifest_before_any_commit(tmp_path, payload, expected_error):
2507
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2508
+
2509
+ _init_bookkeeping_repo(tmp_path)
2510
+ manifest_path = _track_manifest(tmp_path)
2511
+ manifest_path.write_text(payload, encoding="utf-8")
2512
+ (tmp_path / "eligible.txt").write_text("must remain uncommitted\n", encoding="utf-8")
2513
+ before_count = _head_commit_count(tmp_path)
2514
+
2515
+ result = commit_preflight_ready_changes(tmp_path, "R-003")
2516
+
2517
+ assert result.attempted is True
2518
+ assert result.committed is False
2519
+ assert result.result is not None
2520
+ assert expected_error in result.result.stderr
2521
+ assert _head_commit_count(tmp_path) == before_count
2522
+ assert "eligible.txt" not in _head_files(tmp_path)
2523
+ assert subprocess.run(
2524
+ ["git", "status", "--short", "--untracked-files=all"],
2525
+ cwd=tmp_path,
2526
+ check=True,
2527
+ stdout=subprocess.PIPE,
2528
+ text=True,
2529
+ ).stdout.splitlines() == [" M .prizmkit/manifest.json", "?? eligible.txt"]
2530
+
2531
+
2532
+ def test_preflight_ready_commit_preserves_tracked_manifest_with_other_tracked_support(tmp_path):
2407
2533
  from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2408
2534
 
2409
2535
  _init_bookkeeping_repo(tmp_path)
@@ -2417,8 +2543,9 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
2417
2543
  for path in tracked_support_paths:
2418
2544
  path.parent.mkdir(parents=True, exist_ok=True)
2419
2545
  path.write_text("installed\n", encoding="utf-8")
2546
+ tracked_paths = [manifest_path, *tracked_support_paths]
2420
2547
  subprocess.run(
2421
- ["git", "add", manifest_path.relative_to(tmp_path).as_posix(), *(path.relative_to(tmp_path).as_posix() for path in tracked_support_paths)],
2548
+ ["git", "add", *(path.relative_to(tmp_path).as_posix() for path in tracked_paths)],
2422
2549
  cwd=tmp_path,
2423
2550
  check=True,
2424
2551
  )
@@ -2426,6 +2553,8 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
2426
2553
  manifest_path.write_text('{"version":"1.1.128"}\n', encoding="utf-8")
2427
2554
  for path in tracked_support_paths:
2428
2555
  path.write_text("upgraded\n", encoding="utf-8")
2556
+ visible_path = tmp_path / "visible.txt"
2557
+ visible_path.write_text("eligible untracked change\n", encoding="utf-8")
2429
2558
 
2430
2559
  untracked_support_paths = [
2431
2560
  tmp_path / ".claude" / "settings.local.json",
@@ -2436,24 +2565,66 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
2436
2565
  path.parent.mkdir(parents=True, exist_ok=True)
2437
2566
  path.write_text("transient\n", encoding="utf-8")
2438
2567
 
2568
+ before_count = _head_commit_count(tmp_path)
2439
2569
  result = commit_preflight_ready_changes(tmp_path, "B-001")
2440
2570
 
2441
2571
  assert result.attempted is True
2442
- assert result.committed is False
2443
- assert result.result is not None
2444
- assert "installer-owned .prizmkit/manifest.json" in result.result.stderr
2445
- assert _head_message(tmp_path) == "track support"
2572
+ assert result.committed is True
2573
+ assert _head_commit_count(tmp_path) == before_count + 1
2574
+ assert _head_message(tmp_path) == "chore: ready for B-001"
2575
+ assert _head_files(tmp_path) == {
2576
+ *(path.relative_to(tmp_path).as_posix() for path in tracked_paths),
2577
+ visible_path.relative_to(tmp_path).as_posix(),
2578
+ }
2446
2579
  assert subprocess.run(
2447
2580
  ["git", "status", "--short", "--untracked-files=no"],
2448
2581
  cwd=tmp_path,
2449
2582
  check=True,
2450
2583
  stdout=subprocess.PIPE,
2451
2584
  text=True,
2452
- ).stdout.splitlines() == [
2453
- " M .claude/commands/prizmkit.md",
2454
- " M .prizmkit/dev-pipeline/prizmkit_runtime/runners.py",
2455
- " M .prizmkit/manifest.json",
2456
- ]
2585
+ ).stdout == ""
2586
+
2587
+
2588
+ def test_preflight_manifest_deletion_is_absent_from_linked_worktree_created_from_updated_head(tmp_path):
2589
+ from prizmkit_runtime.gitops import (
2590
+ BranchContext,
2591
+ WorktreePolicy,
2592
+ ensure_linked_worktree,
2593
+ guarded_worktree_remove,
2594
+ worktree_runtime_context,
2595
+ )
2596
+ from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
2597
+
2598
+ _init_bookkeeping_repo(tmp_path)
2599
+ manifest_path = _track_manifest(tmp_path)
2600
+ manifest_path.unlink()
2601
+
2602
+ result = commit_preflight_ready_changes(tmp_path, "B-003")
2603
+ runtime = worktree_runtime_context(
2604
+ BranchContext("main", "bugfix/B-003-ready", tmp_path),
2605
+ WorktreePolicy(
2606
+ use_worktree=True,
2607
+ cleanup_on_success=True,
2608
+ preserve_on_failure=True,
2609
+ worktree_root=tmp_path.parent / f"{tmp_path.name}-worktrees",
2610
+ ),
2611
+ )
2612
+ setup = ensure_linked_worktree(runtime)
2613
+
2614
+ assert result.committed is True
2615
+ assert setup.ok
2616
+ try:
2617
+ assert not manifest_path.exists()
2618
+ assert not (runtime.worktree_path / ".prizmkit" / "manifest.json").exists()
2619
+ assert subprocess.run(
2620
+ ["git", "status", "--short", "--untracked-files=no"],
2621
+ cwd=tmp_path,
2622
+ check=True,
2623
+ stdout=subprocess.PIPE,
2624
+ text=True,
2625
+ ).stdout == ""
2626
+ finally:
2627
+ assert guarded_worktree_remove(runtime).ok
2457
2628
 
2458
2629
 
2459
2630
  def test_preflight_ready_commit_excludes_untracked_support_assets_and_state(tmp_path):
@@ -2571,6 +2742,8 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
2571
2742
 
2572
2743
  paths = _make_paths(tmp_path / kind)
2573
2744
  _init_bookkeeping_repo(paths.project_root)
2745
+ manifest_path = _track_manifest(paths.project_root)
2746
+ manifest_path.write_text('{"version":"ready"}\n', encoding="utf-8")
2574
2747
  (paths.project_root / "staged.txt").write_text("staged before run\n", encoding="utf-8")
2575
2748
  subprocess.run(["git", "add", "staged.txt"], cwd=paths.project_root, check=True)
2576
2749
  (paths.project_root / "unstaged.txt").write_text("unstaged before run\n", encoding="utf-8")
@@ -2591,6 +2764,13 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
2591
2764
  {
2592
2765
  "commit_count": _head_commit_count(project_root),
2593
2766
  "message": _head_message(project_root),
2767
+ "manifest_content": subprocess.run(
2768
+ ["git", "show", "HEAD:.prizmkit/manifest.json"],
2769
+ cwd=project_root,
2770
+ check=True,
2771
+ stdout=subprocess.PIPE,
2772
+ text=True,
2773
+ ).stdout,
2594
2774
  "visible_status": subprocess.run(
2595
2775
  ["git", "status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES],
2596
2776
  cwd=project_root,
@@ -2611,11 +2791,14 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
2611
2791
  {
2612
2792
  "commit_count": before_count + 1,
2613
2793
  "message": f"chore: ready for {item_id}",
2794
+ "manifest_content": '{"version":"ready"}\n',
2614
2795
  "visible_status": "",
2615
2796
  }
2616
2797
  ]
2617
2798
  assert _head_commit_count(paths.project_root) == before_count + 1
2618
- assert {"staged.txt", "unstaged.txt", "untracked.txt"}.issubset(_head_files(paths.project_root))
2799
+ assert {".prizmkit/manifest.json", "staged.txt", "unstaged.txt", "untracked.txt"}.issubset(
2800
+ _head_files(paths.project_root)
2801
+ )
2619
2802
 
2620
2803
 
2621
2804
 
@@ -2811,6 +2994,90 @@ def test_classification_accepts_late_infra_error_with_completed_checkpoint_and_c
2811
2994
  assert classification.reason == "semantic_checkpoint_with_late_runtime_error"
2812
2995
 
2813
2996
 
2997
+ def test_classification_accepts_final_success_after_internal_stream_retry(tmp_path):
2998
+ import importlib.util
2999
+
3000
+ from prizmkit_runtime.runner_classification import classify_session
3001
+ from prizmkit_runtime.runner_models import PromptGenerationResult
3002
+ from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
3003
+ from prizmkit_runtime.status import ProgressSummary
3004
+
3005
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
3006
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
3007
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
3008
+ (tmp_path / "base.txt").write_text("base", encoding="utf-8")
3009
+ subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
3010
+ subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
3011
+ base_head = subprocess.run(
3012
+ ["git", "rev-parse", "HEAD"],
3013
+ cwd=tmp_path,
3014
+ check=True,
3015
+ stdout=subprocess.PIPE,
3016
+ text=True,
3017
+ ).stdout.strip()
3018
+ artifact_dir = tmp_path / ".prizmkit" / "bugfix" / "B-004"
3019
+ checkpoint = _write_semantic_completion_checkpoint(tmp_path, artifact_dir, "bugfix-pipeline")
3020
+
3021
+ parser_path = PIPELINE_ROOT / "scripts" / "parse-stream-progress.py"
3022
+ spec = importlib.util.spec_from_file_location("retry_progress_parser", parser_path)
3023
+ module = importlib.util.module_from_spec(spec)
3024
+ assert spec.loader is not None
3025
+ spec.loader.exec_module(module)
3026
+ tracker = module.ProgressTracker()
3027
+ events = [
3028
+ {"type": "session", "id": "pi-session"},
3029
+ {"type": "agent_start"},
3030
+ {"type": "message_end", "message": {"role": "assistant", "content": [], "stopReason": "error", "errorMessage": "Error Code internal_server_error: unexpected EOF"}},
3031
+ {"type": "agent_end", "willRetry": True, "messages": []},
3032
+ {"type": "auto_retry_start", "attempt": 1, "errorMessage": "Error Code internal_server_error: unexpected EOF"},
3033
+ {"type": "agent_start"},
3034
+ {"type": "auto_retry_end", "attempt": 1},
3035
+ {"type": "message_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "WORKFLOW_COMPLETED"}], "stopReason": "stop"}},
3036
+ {"type": "agent_end", "willRetry": False, "messages": [{"role": "assistant", "content": [{"type": "text", "text": "WORKFLOW_COMPLETED"}], "stopReason": "stop"}]},
3037
+ ]
3038
+ for event in events:
3039
+ tracker.process_event(event)
3040
+ data = tracker.to_dict()
3041
+ log = tmp_path / "session.log"
3042
+ log.write_text("[ERROR] transient unexpected EOF\n[SESSION_END] status=success\n", encoding="utf-8")
3043
+ raw = AISessionResult(
3044
+ command=AISessionCommand(("fake",), None, "custom", "stdin"),
3045
+ exit_code=0,
3046
+ timed_out=False,
3047
+ termination_reason=None,
3048
+ stale_marker=None,
3049
+ fatal_error_code=str(data.get("fatal_error_code") or ""),
3050
+ progress_summary=ProgressSummary(
3051
+ result_subtype=str(data.get("result_subtype") or ""),
3052
+ stop_reason=str(data.get("stop_reason") or ""),
3053
+ fatal_error_code=str(data.get("fatal_error_code") or ""),
3054
+ terminal_result_text=str(data.get("terminal_result_text") or ""),
3055
+ errors=tuple(str(item) for item in data.get("errors", [])),
3056
+ ),
3057
+ session_log=log,
3058
+ backup_log=tmp_path / "backup.log",
3059
+ log_recovery=None,
3060
+ started_epoch=0,
3061
+ ended_epoch=1,
3062
+ )
3063
+
3064
+ classification = classify_session(
3065
+ raw,
3066
+ project_root=tmp_path,
3067
+ base_head=base_head,
3068
+ prompt=PromptGenerationResult(
3069
+ output_path=tmp_path / "prompt.md",
3070
+ checkpoint_path=checkpoint,
3071
+ artifact_path=artifact_dir,
3072
+ ),
3073
+ )
3074
+
3075
+ assert data["last_result_is_error"] is False
3076
+ assert any("unexpected EOF" in str(error) for error in data["errors"])
3077
+ assert classification.session_status == "success"
3078
+ assert classification.reason == "terminal_success_with_semantic_checkpoint"
3079
+
3080
+
2814
3081
  def test_classification_rejects_late_infra_with_completion_summary_only(tmp_path):
2815
3082
  from prizmkit_runtime.runner_classification import classify_session
2816
3083
  from prizmkit_runtime.runner_models import PromptGenerationResult