assertion-cli 0.5.15__tar.gz → 0.5.17__tar.gz

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 (35) hide show
  1. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/PKG-INFO +2 -1
  2. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/README.md +1 -0
  3. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/PKG-INFO +2 -1
  4. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/SOURCES.txt +3 -1
  5. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/git.py +54 -0
  6. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/main.py +148 -27
  7. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/session.py +35 -0
  9. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/SKILL.md +4 -1
  10. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_session_start.py +1 -0
  11. assertion_cli-0.5.17/tests/test_verify.py +334 -0
  12. assertion_cli-0.5.17/tests/test_verify_integration.py +277 -0
  13. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/api.py +0 -0
  14. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/dependency_links.txt +0 -0
  15. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/entry_points.txt +0 -0
  16. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/requires.txt +0 -0
  17. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/top_level.txt +0 -0
  18. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/bundle.py +0 -0
  19. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/credentials.py +0 -0
  20. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/link.py +0 -0
  21. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/models.py +0 -0
  22. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/setup.cfg +0 -0
  23. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/AGENTS.md +0 -0
  24. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/__init__.py +0 -0
  25. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_api.py +0 -0
  26. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_auth.py +0 -0
  27. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_bundle.py +0 -0
  28. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_credentials.py +0 -0
  29. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_decision.py +0 -0
  30. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_git.py +0 -0
  31. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_install.py +0 -0
  32. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_link.py +0 -0
  33. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_main.py +0 -0
  34. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_prompt.py +0 -0
  35. {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.15
3
+ Version: 0.5.17
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -47,6 +47,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
48
48
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
49
49
  asrt verify # submit final verification (non-blocking)
50
+ asrt verify --force # submit even when review diff is unchanged
50
51
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
52
  ```
52
53
 
@@ -34,6 +34,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
34
34
  asrt checkpoint --continue "Implemented Y"
35
35
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
36
36
  asrt verify # submit final verification (non-blocking)
37
+ asrt verify --force # submit even when review diff is unchanged
37
38
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
38
39
  ```
39
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.15
3
+ Version: 0.5.17
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -47,6 +47,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
48
48
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
49
49
  asrt verify # submit final verification (non-blocking)
50
+ asrt verify --force # submit even when review diff is unchanged
50
51
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
52
  ```
52
53
 
@@ -28,4 +28,6 @@ tests/test_link.py
28
28
  tests/test_main.py
29
29
  tests/test_prompt.py
30
30
  tests/test_session.py
31
- tests/test_session_start.py
31
+ tests/test_session_start.py
32
+ tests/test_verify.py
33
+ tests/test_verify_integration.py
@@ -180,6 +180,60 @@ def get_upstream_ref(repo_root: Path) -> str | None:
180
180
  return None
181
181
 
182
182
 
183
+ def get_closest_merged_remote_ref(repo_root: Path) -> str | None:
184
+ """Return the newest remote-tracking ref that is an ancestor of ``HEAD``.
185
+
186
+ Used when ``get_upstream_ref`` falls through to a raw merge-base SHA so
187
+ session-start output can still name the branch the user branched from.
188
+ """
189
+ try:
190
+ merged = run_git_command(
191
+ repo_root,
192
+ [
193
+ "branch",
194
+ "-r",
195
+ "--merged",
196
+ "HEAD",
197
+ "--format=%(refname:short)",
198
+ ],
199
+ )
200
+ except RuntimeError:
201
+ return None
202
+
203
+ ancestors = [r for r in merged.splitlines() if r]
204
+ if not ancestors:
205
+ return None
206
+
207
+ newest_ref: str | None = None
208
+ newest_ts = -1
209
+ for ref in ancestors:
210
+ try:
211
+ ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", ref]))
212
+ except (RuntimeError, ValueError):
213
+ continue
214
+ if ts > newest_ts:
215
+ newest_ts = ts
216
+ newest_ref = ref
217
+
218
+ return newest_ref
219
+
220
+
221
+ def format_verify_scope_label(repo_root: Path, upstream: str) -> str:
222
+ """Human-readable label for the upstream used to compute the verify base."""
223
+ if upstream.startswith("origin/") or upstream == "origin/HEAD":
224
+ return f"merge-base with {upstream}"
225
+
226
+ # Step 3 of ``get_upstream_ref`` returns a raw SHA; name the parent branch.
227
+ closest = get_closest_merged_remote_ref(repo_root)
228
+ if closest is not None:
229
+ return f"merge-base with {closest}"
230
+
231
+ if len(upstream) >= 12 and all(c in "0123456789abcdef" for c in upstream.lower()):
232
+ return f"merge-base at {upstream[:12]}"
233
+
234
+ return f"merge-base with {upstream}"
235
+
236
+
183
237
  def get_merge_base(repo_root: Path, upstream: str) -> str:
184
238
  """Return the merge-base of ``HEAD`` and *upstream*."""
185
239
  try:
@@ -17,6 +17,7 @@ from git import (
17
17
  exit_with_error,
18
18
  find_git_root,
19
19
  find_git_root_or_none,
20
+ format_verify_scope_label,
20
21
  get_full_diff,
21
22
  get_head_branch,
22
23
  get_head_sha,
@@ -25,17 +26,23 @@ from git import (
25
26
  is_ancestor,
26
27
  run_git_command,
27
28
  )
28
- from link import load_link, save_link
29
+ from link import LINK_FILE_NAME, load_link, save_link
29
30
  from models import CheckpointResponse, SessionStatus, render_stack_list
30
31
  from session import (
31
32
  ASSERTION_DIR_NAME,
32
33
  METADATA_FILE_NAME,
33
34
  PROMPTS_FILE_NAME,
34
35
  VERIFY_BASE_SHA_FILE_NAME,
36
+ _read_optional_text_file,
35
37
  append_checkpoint_entry,
38
+ compute_review_diff_hash,
36
39
  continue_session,
37
40
  load_existing_session,
38
41
  read_init_verify_base_sha,
42
+ read_review_diff_hash,
43
+ read_verify_last_status,
44
+ save_review_diff_hash,
45
+ save_verify_last_status,
39
46
  start_new_session,
40
47
  update_metadata_anchor,
41
48
  )
@@ -360,8 +367,10 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
360
367
  app.add_typer(session_app, name="session")
361
368
 
362
369
 
363
- def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
364
- """Resolve the verification base SHA.
370
+ def _resolve_verify_scope(
371
+ repo_root: Path, from_ref: str | None
372
+ ) -> tuple[str, str]:
373
+ """Resolve the verification base SHA and a human-readable scope label.
365
374
 
366
375
  If *from_ref* is provided, resolve it and validate that it is an ancestor
367
376
  of HEAD. Otherwise compute the merge-base with the upstream tracking branch,
@@ -374,7 +383,7 @@ def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
374
383
  exit_with_error(f"Invalid --from ref '{from_ref}': {exc}")
375
384
  if not is_ancestor(repo_root, sha, "HEAD"):
376
385
  exit_with_error(f"--from ref {sha[:12]} is not an ancestor of HEAD.")
377
- return sha
386
+ return sha, f"explicit ref {from_ref}"
378
387
 
379
388
  try:
380
389
  run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
@@ -387,13 +396,14 @@ def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
387
396
  "Warning: no upstream tracking branch found, falling back to HEAD.",
388
397
  err=True,
389
398
  )
390
- return get_head_sha(repo_root)
399
+ return get_head_sha(repo_root), "HEAD (no upstream found)"
391
400
  try:
392
- return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
401
+ sha = run_git_command(repo_root, ["merge-base", "HEAD", upstream])
393
402
  except RuntimeError as exc:
394
403
  exit_with_error(
395
404
  f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
396
405
  )
406
+ return sha, format_verify_scope_label(repo_root, upstream)
397
407
 
398
408
 
399
409
  def _resolve_clone_anchor(repo_root: Path, verify_base: str) -> str:
@@ -452,7 +462,7 @@ def session_start(
452
462
  repo_root = find_git_root(Path.cwd())
453
463
  _ensure_gitignore_limits_assertion_state(repo_root)
454
464
 
455
- verify_base = _resolve_verify_base(repo_root, from_ref)
465
+ verify_base, scope_label = _resolve_verify_scope(repo_root, from_ref)
456
466
  assertion_dir = repo_root / ASSERTION_DIR_NAME
457
467
  assertion_dir.mkdir(exist_ok=True)
458
468
  removed = _reset_assertion_dir(assertion_dir)
@@ -460,7 +470,8 @@ def session_start(
460
470
  verify_base_path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
461
471
  verify_base_path.write_text(verify_base + "\n", encoding="utf-8")
462
472
  typer.echo(
463
- f"Recorded verification base {verify_base[:12]} {verify_base_path.relative_to(repo_root)}"
473
+ f"Recorded verification base {verify_base[:12]} ({scope_label}) "
474
+ f"→ {verify_base_path.relative_to(repo_root)}"
464
475
  )
465
476
  if removed:
466
477
  typer.echo(
@@ -603,6 +614,14 @@ def _save_verify_session_id(assertion_dir: Path, session_id: str) -> None:
603
614
  )
604
615
 
605
616
 
617
+ def _read_optional_verify_session_id(assertion_dir: Path) -> str | None:
618
+ path = assertion_dir / VERIFY_SESSION_FILE_NAME
619
+ if not path.exists():
620
+ return None
621
+ content = path.read_text(encoding="utf-8").strip()
622
+ return content or None
623
+
624
+
606
625
  def _load_verify_session_id(assertion_dir: Path) -> str:
607
626
  path = assertion_dir / VERIFY_SESSION_FILE_NAME
608
627
  if not path.exists():
@@ -648,24 +667,11 @@ def _write_screenshots(
648
667
  return screenshot_dir
649
668
 
650
669
 
651
- @app.command("verify")
652
- def verify(
653
- json_output: bool = typer.Option(
654
- False,
655
- "--json",
656
- help="Print the verification submission as a single JSON line.",
657
- ),
658
- ) -> None:
659
- """Submit final verification. Returns immediately without polling.
660
-
661
- Verification can take several minutes on the backend; blocking the agent's
662
- shell long enough to trip a Bash-tool timeout was a real failure mode. So
663
- this command only submits — poll `asrt verify-status` (one-shot) on whatever
664
- interval your harness prefers to learn the outcome.
665
- """
666
- ctx, metadata = load_existing_session(Path.cwd())
667
- stack_id = metadata.stack_id
668
- assert stack_id is not None
670
+ def _build_verify_bundle(
671
+ ctx,
672
+ metadata,
673
+ ) -> tuple[bytes, str, str]:
674
+ """Return (bundle_bytes, review_diff_text, review_diff_hash)."""
669
675
  verify_base = read_init_verify_base_sha(ctx.assertion_dir)
670
676
  clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
671
677
  head_branch = get_head_branch(ctx.repo_root)
@@ -678,7 +684,6 @@ def verify(
678
684
  clone_sha=clone_anchor,
679
685
  head_branch=head_branch,
680
686
  )
681
-
682
687
  bundle_bytes = build_bundle(
683
688
  metadata=metadata,
684
689
  diff_text=diff_text,
@@ -686,6 +691,117 @@ def verify(
686
691
  prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
687
692
  checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
688
693
  )
694
+ return bundle_bytes, review_diff_text, compute_review_diff_hash(review_diff_text)
695
+
696
+
697
+ def _compute_review_diff_fingerprint(ctx) -> tuple[str, str]:
698
+ """Return (review_diff_text, review_diff_hash) without bundle side effects."""
699
+ verify_base = read_init_verify_base_sha(ctx.assertion_dir)
700
+ review_diff_text = get_full_diff(ctx.repo_root, verify_base)
701
+ return review_diff_text, compute_review_diff_hash(review_diff_text)
702
+
703
+
704
+ def _try_reuse_verify(
705
+ assertion_dir: Path,
706
+ *,
707
+ review_diff_hash: str,
708
+ json_output: bool,
709
+ ) -> bool:
710
+ """Reuse a prior verification when the review diff is unchanged.
711
+
712
+ Returns True when an existing session is reused (no new submission). Covers
713
+ both terminal ``succeeded`` runs and in-flight ``created``/``running`` runs
714
+ so a repeated ``asrt verify`` does not submit twice for the same diff.
715
+ """
716
+ stored_hash = read_review_diff_hash(assertion_dir)
717
+ if stored_hash != review_diff_hash:
718
+ return False
719
+
720
+ last_status = read_verify_last_status(assertion_dir)
721
+ reusable_statuses = {
722
+ SessionStatus.SUCCEEDED.value,
723
+ SessionStatus.CREATED.value,
724
+ SessionStatus.RUNNING.value,
725
+ }
726
+ if last_status not in reusable_statuses:
727
+ return False
728
+
729
+ session_id = _read_optional_verify_session_id(assertion_dir)
730
+ if session_id is None:
731
+ return False
732
+
733
+ session_url = _read_optional_text_file(assertion_dir / LINK_FILE_NAME)
734
+ if session_url is None:
735
+ return False
736
+
737
+ if json_output:
738
+ typer.echo(
739
+ json.dumps(
740
+ {
741
+ "session_id": session_id,
742
+ "url": session_url,
743
+ "status": last_status,
744
+ "reused": True,
745
+ }
746
+ )
747
+ )
748
+ elif last_status == SessionStatus.SUCCEEDED.value:
749
+ typer.echo(
750
+ "Review diff unchanged since the last successful verification — "
751
+ f"reusing session {session_id}."
752
+ )
753
+ typer.echo(f"URL: {session_url}")
754
+ typer.echo(
755
+ "Embed `asrt get-link` in the PR body if the branch name may differ."
756
+ )
757
+ else:
758
+ typer.echo(
759
+ "Review diff unchanged since the last verification submission — "
760
+ f"session {session_id} is still {last_status}."
761
+ )
762
+ typer.echo(f"URL: {session_url}")
763
+ typer.echo("Poll `asrt verify-status` instead of submitting again.")
764
+ return True
765
+
766
+
767
+ @app.command("verify")
768
+ def verify(
769
+ json_output: bool = typer.Option(
770
+ False,
771
+ "--json",
772
+ help="Print the verification submission as a single JSON line.",
773
+ ),
774
+ force: bool = typer.Option(
775
+ False,
776
+ "--force",
777
+ help="Submit a new verification even when the review diff is unchanged.",
778
+ ),
779
+ ) -> None:
780
+ """Submit final verification. Returns immediately without polling.
781
+
782
+ Verification can take several minutes on the backend; blocking the agent's
783
+ shell long enough to trip a Bash-tool timeout was a real failure mode. So
784
+ this command only submits — poll `asrt verify-status` (one-shot) on whatever
785
+ interval your harness prefers to learn the outcome.
786
+
787
+ When the review-scope diff is unchanged since the last verification
788
+ submission, the CLI reuses that session instead of submitting again —
789
+ including while a prior run is still ``running``. Pass ``--force`` to always
790
+ submit.
791
+ """
792
+ ctx, metadata = load_existing_session(Path.cwd())
793
+ stack_id = metadata.stack_id
794
+ assert stack_id is not None
795
+ _, review_diff_hash = _compute_review_diff_fingerprint(ctx)
796
+
797
+ if not force and _try_reuse_verify(
798
+ ctx.assertion_dir,
799
+ review_diff_hash=review_diff_hash,
800
+ json_output=json_output,
801
+ ):
802
+ return
803
+
804
+ bundle_bytes, _, review_diff_hash = _build_verify_bundle(ctx, metadata)
689
805
 
690
806
  client = AssertionClient()
691
807
  resp = client.verify(
@@ -698,6 +814,8 @@ def verify(
698
814
  session_url = resp.url
699
815
  save_link(ctx.assertion_dir, session_url)
700
816
  _save_verify_session_id(ctx.assertion_dir, session_id)
817
+ save_review_diff_hash(ctx.assertion_dir, review_diff_hash)
818
+ save_verify_last_status(ctx.assertion_dir, SessionStatus.RUNNING.value)
701
819
 
702
820
  if json_output:
703
821
  typer.echo(
@@ -706,6 +824,7 @@ def verify(
706
824
  "session_id": session_id,
707
825
  "url": session_url,
708
826
  "status": "submitted",
827
+ "reused": False,
709
828
  }
710
829
  )
711
830
  )
@@ -748,6 +867,8 @@ def verify_status(
748
867
  session_url = None
749
868
 
750
869
  terminal = payload.status in {SessionStatus.SUCCEEDED, SessionStatus.FAILED}
870
+ if terminal:
871
+ save_verify_last_status(ctx.assertion_dir, str(payload.status))
751
872
  screenshot_count = len(payload.screenshots)
752
873
  screenshots_saved = False
753
874
  if terminal and payload.screenshots:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.15"
7
+ version = "0.5.17"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  from pathlib import Path
2
3
 
3
4
  from pydantic import ValidationError
@@ -14,6 +15,40 @@ PROMPTS_FILE_NAME = "prompts"
14
15
  CHECKPOINT_FILE_NAME = "checkpoint"
15
16
  METADATA_FILE_NAME = "metadata.json"
16
17
  VERIFY_BASE_SHA_FILE_NAME = "verify_base_sha"
18
+ REVIEW_DIFF_HASH_FILE_NAME = "review_diff_hash"
19
+ VERIFY_LAST_STATUS_FILE_NAME = "verify_last_status"
20
+
21
+
22
+ def compute_review_diff_hash(review_diff_text: str) -> str:
23
+ """Stable hash of the review-scope diff (verify base → working tree)."""
24
+ return hashlib.sha256(review_diff_text.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def _read_optional_text_file(path: Path) -> str | None:
28
+ if not path.exists():
29
+ return None
30
+ content = path.read_text(encoding="utf-8").strip()
31
+ return content or None
32
+
33
+
34
+ def read_review_diff_hash(assertion_dir: Path) -> str | None:
35
+ return _read_optional_text_file(assertion_dir / REVIEW_DIFF_HASH_FILE_NAME)
36
+
37
+
38
+ def save_review_diff_hash(assertion_dir: Path, digest: str) -> None:
39
+ (assertion_dir / REVIEW_DIFF_HASH_FILE_NAME).write_text(
40
+ digest + "\n", encoding="utf-8"
41
+ )
42
+
43
+
44
+ def read_verify_last_status(assertion_dir: Path) -> str | None:
45
+ return _read_optional_text_file(assertion_dir / VERIFY_LAST_STATUS_FILE_NAME)
46
+
47
+
48
+ def save_verify_last_status(assertion_dir: Path, status: str) -> None:
49
+ (assertion_dir / VERIFY_LAST_STATUS_FILE_NAME).write_text(
50
+ status + "\n", encoding="utf-8"
51
+ )
17
52
 
18
53
 
19
54
  def read_init_verify_base_sha(assertion_dir: Path) -> str:
@@ -144,6 +144,8 @@ Do not call `verify` mid-task. It is not a substitute for checkpoints; it is the
144
144
 
145
145
  `verify` is **non-blocking**: it submits the verification and returns immediately with the session id and URL. The verification itself can take several minutes — you poll `asrt verify-status` on your own cadence to learn the outcome. This avoids tripping any shell-timeout in the harness.
146
146
 
147
+ When the review diff is unchanged since the last verification submission, `asrt verify` reuses that session instead of submitting again — including while a prior run is still `running` (poll `asrt verify-status` instead). After a **succeeded** run, reuse skips a redundant submission. Use `asrt verify --force` to submit anyway. After a new git branch, run `asrt session start` only when the verification base should change — check the scope line it prints, or pass `--from <ref>` if the default is wrong.
148
+
147
149
  ```
148
150
  asrt verify # submit; prints session id + URL, exits 0
149
151
  # … then poll until terminal:
@@ -227,6 +229,7 @@ asrt checkpoint --stack <id> "..." # first checkpoint of a session
227
229
  asrt checkpoint "..." # subsequent checkpoints — auto-continues
228
230
  asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
229
231
  asrt verify # submit verification (non-blocking)
232
+ asrt verify --force # submit even when review diff unchanged
230
233
  asrt verify-status # one-shot poll; loop with your own sleep
231
234
  asrt get-link # hidden PR marker — embed in the PR body (required)
232
235
  ```
@@ -244,7 +247,7 @@ When running under a non-interactive harness (CI, benchmark runner, scripts):
244
247
  - Apply changes targeted at the cited failure.
245
248
  - Re-run `asrt checkpoint "<what you changed>" --json` (auto-continues the existing session).
246
249
  - Bound retries: stop after 3 attempts on the same failure mode, or when the same `reasoning` is returned twice in a row.
247
- 4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"}`. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
250
+ 4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"|"succeeded", "reused": bool}`. When `"reused": true`, the review diff matched the last succeeded verification and no new backend run was started. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
248
251
  5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch name first, then by this marker.
249
252
 
250
253
  Do not mix `--json` output with human-rendered output in the same run. Pick one mode and stick with it.
@@ -266,6 +266,7 @@ def test_session_start_records_merge_base_as_verify_base(tmp_path: Path, monkeyp
266
266
  assert base_sha_path.exists()
267
267
  assert base_sha_path.read_text().strip() == merge_base
268
268
  assert merge_base != head_sha
269
+ assert f"merge-base with {upstream}" in result.stdout
269
270
 
270
271
 
271
272
  def test_session_start_errors_when_repo_has_no_commits(
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ import main
10
+ from models import MetadataPayload, SessionStatus, StatusResponse, VerifyResponse
11
+ from session import (
12
+ SessionContext,
13
+ compute_review_diff_hash,
14
+ read_review_diff_hash,
15
+ read_verify_last_status,
16
+ save_review_diff_hash,
17
+ save_verify_last_status,
18
+ )
19
+
20
+
21
+ runner = CliRunner()
22
+
23
+
24
+ def _write_session(assertion_dir: Path) -> None:
25
+ assertion_dir.mkdir(parents=True, exist_ok=True)
26
+ (assertion_dir / "verify_base_sha").write_text("a" * 40 + "\n")
27
+ (assertion_dir / "prompts").write_text(
28
+ json.dumps({"ts": "2026-01-01T00:00:00Z", "text": "build it"}) + "\n"
29
+ )
30
+ (assertion_dir / "checkpoint").write_text("done\n")
31
+ (assertion_dir / "metadata.json").write_text(
32
+ MetadataPayload(session_id="sess-1", stack_id="stack-1").model_dump_json()
33
+ + "\n"
34
+ )
35
+
36
+
37
+ def _session_ctx(assertion_dir: Path, repo_root: Path) -> SessionContext:
38
+ return SessionContext(
39
+ repo_root=repo_root,
40
+ assertion_dir=assertion_dir,
41
+ prompts_path=assertion_dir / "prompts",
42
+ checkpoint_path=assertion_dir / "checkpoint",
43
+ metadata_path=assertion_dir / "metadata.json",
44
+ )
45
+
46
+
47
+ @patch("main.AssertionClient")
48
+ @patch("main._build_verify_bundle")
49
+ @patch("main._compute_review_diff_fingerprint")
50
+ @patch("main.load_existing_session")
51
+ def test_verify_reuses_unchanged_successful_diff(
52
+ mock_load_session,
53
+ mock_fingerprint,
54
+ mock_build_bundle,
55
+ MockClient,
56
+ tmp_path: Path,
57
+ monkeypatch,
58
+ ) -> None:
59
+ assertion_dir = tmp_path / ".assertion"
60
+ _write_session(assertion_dir)
61
+ review_diff = "diff unchanged\n"
62
+ digest = compute_review_diff_hash(review_diff)
63
+ save_review_diff_hash(assertion_dir, digest)
64
+ save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
65
+ (assertion_dir / "verify_session_id").write_text("sess-1\n")
66
+ (assertion_dir / "link").write_text(
67
+ "https://app.tryassertion.com/sessions/sess-1\n"
68
+ )
69
+
70
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
71
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
72
+ mock_fingerprint.return_value = (review_diff, digest)
73
+ mock_build_bundle.return_value = (b"bundle", review_diff, digest)
74
+
75
+ monkeypatch.chdir(tmp_path)
76
+ result = runner.invoke(main.app, ["verify"])
77
+
78
+ assert result.exit_code == 0
79
+ assert "reusing session sess-1" in result.stdout
80
+ MockClient.return_value.verify.assert_not_called()
81
+ mock_build_bundle.assert_not_called()
82
+
83
+
84
+ @patch("main.AssertionClient")
85
+ @patch("main._build_verify_bundle")
86
+ @patch("main._compute_review_diff_fingerprint")
87
+ @patch("main.load_existing_session")
88
+ def test_verify_reuses_in_flight_running_diff(
89
+ mock_load_session,
90
+ mock_fingerprint,
91
+ mock_build_bundle,
92
+ MockClient,
93
+ tmp_path: Path,
94
+ monkeypatch,
95
+ ) -> None:
96
+ assertion_dir = tmp_path / ".assertion"
97
+ _write_session(assertion_dir)
98
+ review_diff = "diff unchanged\n"
99
+ digest = compute_review_diff_hash(review_diff)
100
+ save_review_diff_hash(assertion_dir, digest)
101
+ save_verify_last_status(assertion_dir, SessionStatus.RUNNING.value)
102
+ (assertion_dir / "verify_session_id").write_text("sess-1\n")
103
+ (assertion_dir / "link").write_text(
104
+ "https://app.tryassertion.com/sessions/sess-1\n"
105
+ )
106
+
107
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
108
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
109
+ mock_fingerprint.return_value = (review_diff, digest)
110
+
111
+ monkeypatch.chdir(tmp_path)
112
+ result = runner.invoke(main.app, ["verify"])
113
+
114
+ assert result.exit_code == 0
115
+ assert "session sess-1 is still running" in result.stdout
116
+ assert "Poll `asrt verify-status`" in result.stdout
117
+ MockClient.return_value.verify.assert_not_called()
118
+ mock_build_bundle.assert_not_called()
119
+
120
+
121
+ @patch("main.AssertionClient")
122
+ @patch("main._build_verify_bundle")
123
+ @patch("main._compute_review_diff_fingerprint")
124
+ @patch("main.load_existing_session")
125
+ def test_verify_submits_when_diff_changed(
126
+ mock_load_session,
127
+ mock_fingerprint,
128
+ mock_build_bundle,
129
+ MockClient,
130
+ tmp_path: Path,
131
+ monkeypatch,
132
+ ) -> None:
133
+ assertion_dir = tmp_path / ".assertion"
134
+ _write_session(assertion_dir)
135
+ old_digest = compute_review_diff_hash("old diff\n")
136
+ save_review_diff_hash(assertion_dir, old_digest)
137
+ save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
138
+
139
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
140
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
141
+ new_diff = "new diff\n"
142
+ new_digest = compute_review_diff_hash(new_diff)
143
+ mock_fingerprint.return_value = (new_diff, new_digest)
144
+ mock_build_bundle.return_value = (b"bundle", new_diff, new_digest)
145
+ MockClient.return_value.verify.return_value = VerifyResponse(
146
+ session_id="sess-1",
147
+ url="https://app.tryassertion.com/sessions/sess-1",
148
+ )
149
+
150
+ monkeypatch.chdir(tmp_path)
151
+ result = runner.invoke(main.app, ["verify"])
152
+
153
+ assert result.exit_code == 0
154
+ assert "Verification submitted" in result.stdout
155
+ MockClient.return_value.verify.assert_called_once()
156
+ assert read_review_diff_hash(assertion_dir) == new_digest
157
+ assert read_verify_last_status(assertion_dir) == SessionStatus.RUNNING.value
158
+
159
+
160
+ @patch("main.AssertionClient")
161
+ @patch("main._build_verify_bundle")
162
+ @patch("main._compute_review_diff_fingerprint")
163
+ @patch("main.load_existing_session")
164
+ def test_verify_does_not_reuse_after_failure(
165
+ mock_load_session,
166
+ mock_fingerprint,
167
+ mock_build_bundle,
168
+ MockClient,
169
+ tmp_path: Path,
170
+ monkeypatch,
171
+ ) -> None:
172
+ assertion_dir = tmp_path / ".assertion"
173
+ _write_session(assertion_dir)
174
+ review_diff = "same diff\n"
175
+ digest = compute_review_diff_hash(review_diff)
176
+ save_review_diff_hash(assertion_dir, digest)
177
+ save_verify_last_status(assertion_dir, SessionStatus.FAILED.value)
178
+
179
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
180
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
181
+ mock_fingerprint.return_value = (review_diff, digest)
182
+ mock_build_bundle.return_value = (b"bundle", review_diff, digest)
183
+ MockClient.return_value.verify.return_value = VerifyResponse(
184
+ session_id="sess-1",
185
+ url="https://app.tryassertion.com/sessions/sess-1",
186
+ )
187
+
188
+ monkeypatch.chdir(tmp_path)
189
+ result = runner.invoke(main.app, ["verify"])
190
+
191
+ assert result.exit_code == 0
192
+ MockClient.return_value.verify.assert_called_once()
193
+
194
+
195
+ @patch("main.AssertionClient")
196
+ @patch("main._build_verify_bundle")
197
+ @patch("main._compute_review_diff_fingerprint")
198
+ @patch("main.load_existing_session")
199
+ def test_verify_force_bypasses_reuse(
200
+ mock_load_session,
201
+ mock_fingerprint,
202
+ mock_build_bundle,
203
+ MockClient,
204
+ tmp_path: Path,
205
+ monkeypatch,
206
+ ) -> None:
207
+ assertion_dir = tmp_path / ".assertion"
208
+ _write_session(assertion_dir)
209
+ review_diff = "same diff\n"
210
+ digest = compute_review_diff_hash(review_diff)
211
+ save_review_diff_hash(assertion_dir, digest)
212
+ save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
213
+ (assertion_dir / "verify_session_id").write_text("sess-1\n")
214
+ (assertion_dir / "link").write_text(
215
+ "https://app.tryassertion.com/sessions/sess-1\n"
216
+ )
217
+
218
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
219
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
220
+ mock_fingerprint.return_value = (review_diff, digest)
221
+ mock_build_bundle.return_value = (b"bundle", review_diff, digest)
222
+ MockClient.return_value.verify.return_value = VerifyResponse(
223
+ session_id="sess-1",
224
+ url="https://app.tryassertion.com/sessions/sess-1",
225
+ )
226
+
227
+ monkeypatch.chdir(tmp_path)
228
+ result = runner.invoke(main.app, ["verify", "--force"])
229
+
230
+ assert result.exit_code == 0
231
+ MockClient.return_value.verify.assert_called_once()
232
+
233
+
234
+ @patch("main.AssertionClient")
235
+ @patch("main._load_verify_session_id")
236
+ @patch("main.load_existing_session")
237
+ def test_verify_status_persists_terminal_status(
238
+ mock_load_session,
239
+ mock_load_session_id,
240
+ MockClient,
241
+ tmp_path: Path,
242
+ monkeypatch,
243
+ ) -> None:
244
+ assertion_dir = tmp_path / ".assertion"
245
+ _write_session(assertion_dir)
246
+
247
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
248
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
249
+ mock_load_session_id.return_value = "sess-1"
250
+ MockClient.return_value.status.return_value = StatusResponse(
251
+ status=SessionStatus.SUCCEEDED,
252
+ message="ok",
253
+ url="https://app.tryassertion.com/sessions/sess-1",
254
+ )
255
+
256
+ monkeypatch.chdir(tmp_path)
257
+ result = runner.invoke(main.app, ["verify-status"])
258
+
259
+ assert result.exit_code == 0
260
+ assert read_verify_last_status(assertion_dir) == SessionStatus.SUCCEEDED.value
261
+
262
+
263
+ @patch("main.AssertionClient")
264
+ @patch("main._build_verify_bundle")
265
+ @patch("main._compute_review_diff_fingerprint")
266
+ @patch("main.load_existing_session")
267
+ def test_verify_reuse_json_output(
268
+ mock_load_session,
269
+ mock_fingerprint,
270
+ mock_build_bundle,
271
+ MockClient,
272
+ tmp_path: Path,
273
+ monkeypatch,
274
+ ) -> None:
275
+ assertion_dir = tmp_path / ".assertion"
276
+ _write_session(assertion_dir)
277
+ review_diff = "diff unchanged\n"
278
+ digest = compute_review_diff_hash(review_diff)
279
+ save_review_diff_hash(assertion_dir, digest)
280
+ save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
281
+ (assertion_dir / "verify_session_id").write_text("sess-1\n")
282
+ (assertion_dir / "link").write_text(
283
+ "https://app.tryassertion.com/sessions/sess-1\n"
284
+ )
285
+
286
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
287
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
288
+ mock_fingerprint.return_value = (review_diff, digest)
289
+ mock_build_bundle.return_value = (b"bundle", review_diff, digest)
290
+
291
+ monkeypatch.chdir(tmp_path)
292
+ result = runner.invoke(main.app, ["verify", "--json"])
293
+
294
+ assert result.exit_code == 0
295
+ payload = json.loads(result.stdout)
296
+ assert payload["reused"] is True
297
+ assert payload["status"] == "succeeded"
298
+ MockClient.return_value.verify.assert_not_called()
299
+ mock_build_bundle.assert_not_called()
300
+
301
+
302
+ @patch("main.AssertionClient")
303
+ @patch("main._build_verify_bundle")
304
+ @patch("main._compute_review_diff_fingerprint")
305
+ @patch("main.load_existing_session")
306
+ def test_verify_missing_session_id_falls_through_to_submit(
307
+ mock_load_session,
308
+ mock_fingerprint,
309
+ mock_build_bundle,
310
+ MockClient,
311
+ tmp_path: Path,
312
+ monkeypatch,
313
+ ) -> None:
314
+ assertion_dir = tmp_path / ".assertion"
315
+ _write_session(assertion_dir)
316
+ review_diff = "same diff\n"
317
+ digest = compute_review_diff_hash(review_diff)
318
+ save_review_diff_hash(assertion_dir, digest)
319
+ save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
320
+
321
+ metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
322
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
323
+ mock_fingerprint.return_value = (review_diff, digest)
324
+ mock_build_bundle.return_value = (b"bundle", review_diff, digest)
325
+ MockClient.return_value.verify.return_value = VerifyResponse(
326
+ session_id="sess-1",
327
+ url="https://app.tryassertion.com/sessions/sess-1",
328
+ )
329
+
330
+ monkeypatch.chdir(tmp_path)
331
+ result = runner.invoke(main.app, ["verify"])
332
+
333
+ assert result.exit_code == 0
334
+ MockClient.return_value.verify.assert_called_once()
@@ -0,0 +1,277 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ import main
12
+ from git import format_verify_scope_label, get_closest_merged_remote_ref
13
+ from models import MetadataPayload, SessionStatus, VerifyResponse
14
+ from session import (
15
+ REVIEW_DIFF_HASH_FILE_NAME,
16
+ VERIFY_LAST_STATUS_FILE_NAME,
17
+ compute_review_diff_hash,
18
+ read_review_diff_hash,
19
+ read_verify_last_status,
20
+ )
21
+
22
+
23
+ runner = CliRunner()
24
+
25
+ _GIT_ENV = {
26
+ "GIT_AUTHOR_NAME": "T",
27
+ "GIT_AUTHOR_EMAIL": "t@t",
28
+ "GIT_COMMITTER_NAME": "T",
29
+ "GIT_COMMITTER_EMAIL": "t@t",
30
+ "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
31
+ }
32
+
33
+
34
+ def _run_git(repo: Path, *args: str) -> str:
35
+ result = subprocess.run(
36
+ ["git", *args],
37
+ cwd=repo,
38
+ capture_output=True,
39
+ check=True,
40
+ env={**_GIT_ENV, "HOME": str(repo)},
41
+ text=True,
42
+ )
43
+ return result.stdout.strip()
44
+
45
+
46
+ def _setup_branching_repo(repo: Path) -> str:
47
+ _run_git(repo, "init")
48
+ _run_git(repo, "remote", "add", "origin", "http://example.com/repo.git")
49
+ _run_git(repo, "commit", "--allow-empty", "-m", "main")
50
+ main_sha = _run_git(repo, "rev-parse", "HEAD")
51
+ _run_git(repo, "update-ref", "refs/remotes/origin/main", main_sha)
52
+
53
+ _run_git(repo, "checkout", "-b", "feature-base")
54
+ _run_git(repo, "commit", "--allow-empty", "-m", "base work")
55
+ feature_base_sha = _run_git(repo, "rev-parse", "HEAD")
56
+ _run_git(repo, "update-ref", "refs/remotes/origin/feature-base", feature_base_sha)
57
+
58
+ _run_git(repo, "checkout", "-b", "feature-follow-up")
59
+ _run_git(repo, "commit", "--allow-empty", "-m", "follow-up")
60
+ return feature_base_sha
61
+
62
+
63
+ def _write_assertion_session(repo: Path) -> None:
64
+ assertion = repo / ".assertion"
65
+ assertion.mkdir()
66
+ base = _run_git(repo, "merge-base", "HEAD", "origin/feature-base")
67
+ (assertion / "verify_base_sha").write_text(base + "\n")
68
+ (assertion / "prompts").write_text(
69
+ json.dumps({"ts": "2026-01-01T00:00:00Z", "text": "do work"}) + "\n"
70
+ )
71
+ (assertion / "checkpoint").write_text("ready\n")
72
+ (assertion / "metadata.json").write_text(
73
+ MetadataPayload(session_id="sess-int", stack_id="stack-1").model_dump_json()
74
+ + "\n"
75
+ )
76
+
77
+
78
+ def test_format_verify_scope_label_names_parent_branch(tmp_path: Path) -> None:
79
+ feature_base_sha = _setup_branching_repo(tmp_path)
80
+ upstream = _run_git(
81
+ tmp_path, "merge-base", "HEAD", "origin/main", "origin/feature-base"
82
+ )
83
+ assert upstream == feature_base_sha
84
+ assert get_closest_merged_remote_ref(tmp_path) == "origin/feature-base"
85
+ assert format_verify_scope_label(tmp_path, upstream) == (
86
+ "merge-base with origin/feature-base"
87
+ )
88
+
89
+
90
+ def test_format_verify_scope_label_keeps_named_upstream(tmp_path: Path) -> None:
91
+ assert (
92
+ format_verify_scope_label(tmp_path, "origin/main")
93
+ == "merge-base with origin/main"
94
+ )
95
+
96
+
97
+ @patch("main.AssertionClient")
98
+ def test_build_verify_bundle_uses_recorded_base(MockClient, tmp_path: Path) -> None:
99
+ feature_base_sha = _setup_branching_repo(tmp_path)
100
+ _write_assertion_session(tmp_path)
101
+
102
+ from session import SessionContext, _load_metadata
103
+
104
+ ctx = SessionContext(
105
+ repo_root=tmp_path,
106
+ assertion_dir=tmp_path / ".assertion",
107
+ prompts_path=tmp_path / ".assertion" / "prompts",
108
+ checkpoint_path=tmp_path / ".assertion" / "checkpoint",
109
+ metadata_path=tmp_path / ".assertion" / "metadata.json",
110
+ )
111
+ metadata = _load_metadata(ctx.metadata_path)
112
+ assert metadata is not None
113
+
114
+ bundle_bytes, review_diff, digest = main._build_verify_bundle(ctx, metadata)
115
+ assert digest == compute_review_diff_hash(review_diff)
116
+ assert feature_base_sha in review_diff or review_diff == ""
117
+ assert bundle_bytes.startswith(b"PK")
118
+
119
+
120
+ @patch("main.AssertionClient")
121
+ def test_verify_end_to_end_git_flow_submits_bundle(MockClient, tmp_path: Path, monkeypatch) -> None:
122
+ _setup_branching_repo(tmp_path)
123
+ _write_assertion_session(tmp_path)
124
+ MockClient.return_value.verify.return_value = VerifyResponse(
125
+ session_id="sess-int",
126
+ url="https://app.tryassertion.com/sessions/sess-int",
127
+ )
128
+ monkeypatch.chdir(tmp_path)
129
+
130
+ result = runner.invoke(main.app, ["verify"])
131
+ assert result.exit_code == 0, result.stdout + result.stderr
132
+ assert "Verification submitted" in result.stdout
133
+ MockClient.return_value.verify.assert_called_once()
134
+ args, kwargs = MockClient.return_value.verify.call_args
135
+ assert kwargs["bundle_bytes"].startswith(b"PK")
136
+ assert read_review_diff_hash(tmp_path / ".assertion") is not None
137
+ assert read_verify_last_status(tmp_path / ".assertion") == SessionStatus.RUNNING.value
138
+
139
+
140
+ @patch("main.AssertionClient")
141
+ def test_verify_double_submit_avoids_second_api_call(
142
+ MockClient, tmp_path: Path, monkeypatch
143
+ ) -> None:
144
+ """Repeated verify with unchanged diff must not submit twice while in-flight."""
145
+ _setup_branching_repo(tmp_path)
146
+ _write_assertion_session(tmp_path)
147
+ MockClient.return_value.verify.return_value = VerifyResponse(
148
+ session_id="sess-int",
149
+ url="https://app.tryassertion.com/sessions/sess-int",
150
+ )
151
+ monkeypatch.chdir(tmp_path)
152
+
153
+ first = runner.invoke(main.app, ["verify"])
154
+ assert first.exit_code == 0, first.stdout + first.stderr
155
+ assert "Verification submitted" in first.stdout
156
+
157
+ second = runner.invoke(main.app, ["verify"])
158
+ assert second.exit_code == 0, second.stdout + second.stderr
159
+ assert "Poll `asrt verify-status`" in second.stdout
160
+ assert MockClient.return_value.verify.call_count == 1
161
+
162
+
163
+ @patch("main.AssertionClient")
164
+ def test_verify_review_diff_hash_stable_across_invocations(
165
+ MockClient, tmp_path: Path, monkeypatch
166
+ ) -> None:
167
+ _setup_branching_repo(tmp_path)
168
+ _write_assertion_session(tmp_path)
169
+ MockClient.return_value.verify.return_value = VerifyResponse(
170
+ session_id="sess-int",
171
+ url="https://app.tryassertion.com/sessions/sess-int",
172
+ )
173
+ monkeypatch.chdir(tmp_path)
174
+
175
+ runner.invoke(main.app, ["verify"])
176
+ hash_after_first = read_review_diff_hash(tmp_path / ".assertion")
177
+
178
+ runner.invoke(main.app, ["verify"])
179
+ hash_after_second = read_review_diff_hash(tmp_path / ".assertion")
180
+
181
+ assert hash_after_first is not None
182
+ assert hash_after_first == hash_after_second
183
+
184
+
185
+ @patch("main.AssertionClient")
186
+ def test_verify_reuse_after_status_succeeded(MockClient, tmp_path: Path, monkeypatch) -> None:
187
+ _setup_branching_repo(tmp_path)
188
+ _write_assertion_session(tmp_path)
189
+ MockClient.return_value.verify.return_value = VerifyResponse(
190
+ session_id="sess-int",
191
+ url="https://app.tryassertion.com/sessions/sess-int",
192
+ )
193
+ monkeypatch.chdir(tmp_path)
194
+
195
+ first = runner.invoke(main.app, ["verify"])
196
+ assert first.exit_code == 0
197
+ (tmp_path / ".assertion" / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
198
+
199
+ second = runner.invoke(main.app, ["verify"])
200
+ assert second.exit_code == 0
201
+ assert "reusing session" in second.stdout
202
+ assert MockClient.return_value.verify.call_count == 1
203
+
204
+
205
+ @patch("main.AssertionClient")
206
+ def test_verify_status_running_does_not_erase_succeeded(
207
+ MockClient, tmp_path: Path, monkeypatch
208
+ ) -> None:
209
+ _setup_branching_repo(tmp_path)
210
+ _write_assertion_session(tmp_path)
211
+ assertion = tmp_path / ".assertion"
212
+ (assertion / "verify_session_id").write_text("sess-int\n")
213
+ (assertion / "link").write_text("https://app.tryassertion.com/sessions/sess-int\n")
214
+ (assertion / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
215
+ (assertion / REVIEW_DIFF_HASH_FILE_NAME).write_text("deadbeef\n")
216
+
217
+ from models import StatusResponse
218
+
219
+ MockClient.return_value.status.return_value = StatusResponse(
220
+ status=SessionStatus.RUNNING,
221
+ message="still going",
222
+ url="https://app.tryassertion.com/sessions/sess-int",
223
+ )
224
+ monkeypatch.chdir(tmp_path)
225
+
226
+ result = runner.invoke(main.app, ["verify-status"])
227
+ assert result.exit_code == 0
228
+ assert read_verify_last_status(assertion) == "succeeded"
229
+
230
+
231
+ def test_session_start_clears_verify_reuse_state(tmp_path: Path, monkeypatch) -> None:
232
+ _setup_branching_repo(tmp_path)
233
+ assertion = tmp_path / ".assertion"
234
+ assertion.mkdir()
235
+ (assertion / REVIEW_DIFF_HASH_FILE_NAME).write_text("hash\n")
236
+ (assertion / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
237
+ (assertion / "old_state").write_text("x\n")
238
+ monkeypatch.chdir(tmp_path)
239
+
240
+ result = runner.invoke(main.app, ["session", "start"])
241
+ assert result.exit_code == 0
242
+ assert not (assertion / REVIEW_DIFF_HASH_FILE_NAME).exists()
243
+ assert not (assertion / VERIFY_LAST_STATUS_FILE_NAME).exists()
244
+ assert not (assertion / "old_state").exists()
245
+
246
+
247
+ @patch("main.AssertionClient")
248
+ @patch("session._require_repo_ready")
249
+ @patch("session._require_prompts_file")
250
+ def test_checkpoint_still_builds_bundle_after_verify_refactor(
251
+ mock_require_prompts,
252
+ mock_require_repo_ready,
253
+ MockClient,
254
+ tmp_path: Path,
255
+ monkeypatch,
256
+ ) -> None:
257
+ """Checkpoint path must remain independent of verify bundle helper."""
258
+ _setup_branching_repo(tmp_path)
259
+ _write_assertion_session(tmp_path)
260
+ mock_require_repo_ready.return_value = tmp_path
261
+ from models import CheckpointResponse, ReviewDetail
262
+
263
+ MockClient.return_value.checkpoint.return_value = CheckpointResponse(
264
+ checkpoint_id="cp-1",
265
+ outcome="succeeded",
266
+ reasoning="ok",
267
+ reviews=[ReviewDetail(model="m", approved=True, feedback="ok")],
268
+ created_at="2026-01-01T00:00:00+00:00",
269
+ updated_at="2026-01-01T00:00:00+00:00",
270
+ )
271
+ monkeypatch.chdir(tmp_path)
272
+
273
+ result = runner.invoke(main.app, ["checkpoint", "still works"])
274
+ assert result.exit_code == 0, result.stdout + result.stderr
275
+ MockClient.return_value.checkpoint.assert_called_once()
276
+ args, kwargs = MockClient.return_value.checkpoint.call_args
277
+ assert kwargs["bundle_bytes"].startswith(b"PK")
File without changes
File without changes
File without changes
File without changes
File without changes