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.
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/PKG-INFO +2 -1
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/README.md +1 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/PKG-INFO +2 -1
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/SOURCES.txt +3 -1
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/git.py +54 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/main.py +148 -27
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/pyproject.toml +1 -1
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/session.py +35 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/SKILL.md +4 -1
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_session_start.py +1 -0
- assertion_cli-0.5.17/tests/test_verify.py +334 -0
- assertion_cli-0.5.17/tests/test_verify_integration.py +277 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/api.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/bundle.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/credentials.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/link.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/models.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/setup.cfg +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/templates/__init__.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_api.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_auth.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_credentials.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_git.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_install.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_link.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_main.py +0 -0
- {assertion_cli-0.5.15 → assertion_cli-0.5.17}/tests/test_prompt.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
364
|
-
|
|
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
|
-
|
|
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 =
|
|
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]}
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|