assertion-cli 0.5.16__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.16 → assertion_cli-0.5.17}/PKG-INFO +1 -1
  2. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/main.py +35 -13
  4. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/pyproject.toml +1 -1
  5. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/templates/SKILL.md +1 -1
  6. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_verify.py +78 -76
  7. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_verify_integration.py +45 -0
  8. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/README.md +0 -0
  9. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/api.py +0 -0
  10. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/SOURCES.txt +0 -0
  11. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/dependency_links.txt +0 -0
  12. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/entry_points.txt +0 -0
  13. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/requires.txt +0 -0
  14. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/assertion_cli.egg-info/top_level.txt +0 -0
  15. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/bundle.py +0 -0
  16. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/credentials.py +0 -0
  17. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/git.py +0 -0
  18. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/link.py +0 -0
  19. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/models.py +0 -0
  20. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/session.py +0 -0
  21. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/setup.cfg +0 -0
  22. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/templates/AGENTS.md +0 -0
  23. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/templates/__init__.py +0 -0
  24. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_api.py +0 -0
  25. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_auth.py +0 -0
  26. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_bundle.py +0 -0
  27. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_credentials.py +0 -0
  28. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_decision.py +0 -0
  29. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_git.py +0 -0
  30. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_install.py +0 -0
  31. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_link.py +0 -0
  32. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_main.py +0 -0
  33. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_prompt.py +0 -0
  34. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_session.py +0 -0
  35. {assertion_cli-0.5.16 → assertion_cli-0.5.17}/tests/test_session_start.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.16
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.16
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
@@ -694,22 +694,36 @@ def _build_verify_bundle(
694
694
  return bundle_bytes, review_diff_text, compute_review_diff_hash(review_diff_text)
695
695
 
696
696
 
697
- def _try_reuse_successful_verify(
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(
698
705
  assertion_dir: Path,
699
706
  *,
700
707
  review_diff_hash: str,
701
708
  json_output: bool,
702
709
  ) -> bool:
703
- """Reuse the prior verification when the review diff is unchanged.
710
+ """Reuse a prior verification when the review diff is unchanged.
704
711
 
705
- Returns True when a prior succeeded verification is reused (no new submission).
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.
706
715
  """
707
716
  stored_hash = read_review_diff_hash(assertion_dir)
708
717
  if stored_hash != review_diff_hash:
709
718
  return False
710
719
 
711
720
  last_status = read_verify_last_status(assertion_dir)
712
- if last_status != SessionStatus.SUCCEEDED.value:
721
+ reusable_statuses = {
722
+ SessionStatus.SUCCEEDED.value,
723
+ SessionStatus.CREATED.value,
724
+ SessionStatus.RUNNING.value,
725
+ }
726
+ if last_status not in reusable_statuses:
713
727
  return False
714
728
 
715
729
  session_id = _read_optional_verify_session_id(assertion_dir)
@@ -726,12 +740,12 @@ def _try_reuse_successful_verify(
726
740
  {
727
741
  "session_id": session_id,
728
742
  "url": session_url,
729
- "status": SessionStatus.SUCCEEDED.value,
743
+ "status": last_status,
730
744
  "reused": True,
731
745
  }
732
746
  )
733
747
  )
734
- else:
748
+ elif last_status == SessionStatus.SUCCEEDED.value:
735
749
  typer.echo(
736
750
  "Review diff unchanged since the last successful verification — "
737
751
  f"reusing session {session_id}."
@@ -740,6 +754,13 @@ def _try_reuse_successful_verify(
740
754
  typer.echo(
741
755
  "Embed `asrt get-link` in the PR body if the branch name may differ."
742
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.")
743
764
  return True
744
765
 
745
766
 
@@ -763,24 +784,25 @@ def verify(
763
784
  this command only submits — poll `asrt verify-status` (one-shot) on whatever
764
785
  interval your harness prefers to learn the outcome.
765
786
 
766
- When the review-scope diff is unchanged since the last *succeeded*
767
- verification, the CLI reuses that result instead of submitting again.
768
- Pass ``--force`` to always submit.
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.
769
791
  """
770
792
  ctx, metadata = load_existing_session(Path.cwd())
771
793
  stack_id = metadata.stack_id
772
794
  assert stack_id is not None
773
- bundle_bytes, review_diff_text, review_diff_hash = _build_verify_bundle(
774
- ctx, metadata
775
- )
795
+ _, review_diff_hash = _compute_review_diff_fingerprint(ctx)
776
796
 
777
- if not force and _try_reuse_successful_verify(
797
+ if not force and _try_reuse_verify(
778
798
  ctx.assertion_dir,
779
799
  review_diff_hash=review_diff_hash,
780
800
  json_output=json_output,
781
801
  ):
782
802
  return
783
803
 
804
+ bundle_bytes, _, review_diff_hash = _build_verify_bundle(ctx, metadata)
805
+
784
806
  client = AssertionClient()
785
807
  resp = client.verify(
786
808
  stack_id=stack_id,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.16"
7
+ version = "0.5.17"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -144,7 +144,7 @@ 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 **succeeded** verification, `asrt verify` reuses that session instead of submitting again. 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.
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
148
 
149
149
  ```
150
150
  asrt verify # submit; prints session id + URL, exits 0
@@ -9,6 +9,7 @@ from typer.testing import CliRunner
9
9
  import main
10
10
  from models import MetadataPayload, SessionStatus, StatusResponse, VerifyResponse
11
11
  from session import (
12
+ SessionContext,
12
13
  compute_review_diff_hash,
13
14
  read_review_diff_hash,
14
15
  read_verify_last_status,
@@ -33,11 +34,23 @@ def _write_session(assertion_dir: Path) -> None:
33
34
  )
34
35
 
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
+
36
47
  @patch("main.AssertionClient")
37
48
  @patch("main._build_verify_bundle")
49
+ @patch("main._compute_review_diff_fingerprint")
38
50
  @patch("main.load_existing_session")
39
51
  def test_verify_reuses_unchanged_successful_diff(
40
52
  mock_load_session,
53
+ mock_fingerprint,
41
54
  mock_build_bundle,
42
55
  MockClient,
43
56
  tmp_path: Path,
@@ -54,17 +67,9 @@ def test_verify_reuses_unchanged_successful_diff(
54
67
  "https://app.tryassertion.com/sessions/sess-1\n"
55
68
  )
56
69
 
57
- from session import SessionContext
58
-
59
- ctx = SessionContext(
60
- repo_root=tmp_path,
61
- assertion_dir=assertion_dir,
62
- prompts_path=assertion_dir / "prompts",
63
- checkpoint_path=assertion_dir / "checkpoint",
64
- metadata_path=assertion_dir / "metadata.json",
65
- )
66
70
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
67
- mock_load_session.return_value = (ctx, metadata)
71
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
72
+ mock_fingerprint.return_value = (review_diff, digest)
68
73
  mock_build_bundle.return_value = (b"bundle", review_diff, digest)
69
74
 
70
75
  monkeypatch.chdir(tmp_path)
@@ -73,13 +78,53 @@ def test_verify_reuses_unchanged_successful_diff(
73
78
  assert result.exit_code == 0
74
79
  assert "reusing session sess-1" in result.stdout
75
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()
76
119
 
77
120
 
78
121
  @patch("main.AssertionClient")
79
122
  @patch("main._build_verify_bundle")
123
+ @patch("main._compute_review_diff_fingerprint")
80
124
  @patch("main.load_existing_session")
81
125
  def test_verify_submits_when_diff_changed(
82
126
  mock_load_session,
127
+ mock_fingerprint,
83
128
  mock_build_bundle,
84
129
  MockClient,
85
130
  tmp_path: Path,
@@ -91,23 +136,12 @@ def test_verify_submits_when_diff_changed(
91
136
  save_review_diff_hash(assertion_dir, old_digest)
92
137
  save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
93
138
 
94
- from session import SessionContext
95
-
96
- ctx = SessionContext(
97
- repo_root=tmp_path,
98
- assertion_dir=assertion_dir,
99
- prompts_path=assertion_dir / "prompts",
100
- checkpoint_path=assertion_dir / "checkpoint",
101
- metadata_path=assertion_dir / "metadata.json",
102
- )
103
139
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
104
- mock_load_session.return_value = (ctx, metadata)
140
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
105
141
  new_diff = "new diff\n"
106
- mock_build_bundle.return_value = (
107
- b"bundle",
108
- new_diff,
109
- compute_review_diff_hash(new_diff),
110
- )
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)
111
145
  MockClient.return_value.verify.return_value = VerifyResponse(
112
146
  session_id="sess-1",
113
147
  url="https://app.tryassertion.com/sessions/sess-1",
@@ -119,15 +153,17 @@ def test_verify_submits_when_diff_changed(
119
153
  assert result.exit_code == 0
120
154
  assert "Verification submitted" in result.stdout
121
155
  MockClient.return_value.verify.assert_called_once()
122
- assert read_review_diff_hash(assertion_dir) == compute_review_diff_hash(new_diff)
156
+ assert read_review_diff_hash(assertion_dir) == new_digest
123
157
  assert read_verify_last_status(assertion_dir) == SessionStatus.RUNNING.value
124
158
 
125
159
 
126
160
  @patch("main.AssertionClient")
127
161
  @patch("main._build_verify_bundle")
162
+ @patch("main._compute_review_diff_fingerprint")
128
163
  @patch("main.load_existing_session")
129
164
  def test_verify_does_not_reuse_after_failure(
130
165
  mock_load_session,
166
+ mock_fingerprint,
131
167
  mock_build_bundle,
132
168
  MockClient,
133
169
  tmp_path: Path,
@@ -140,17 +176,9 @@ def test_verify_does_not_reuse_after_failure(
140
176
  save_review_diff_hash(assertion_dir, digest)
141
177
  save_verify_last_status(assertion_dir, SessionStatus.FAILED.value)
142
178
 
143
- from session import SessionContext
144
-
145
- ctx = SessionContext(
146
- repo_root=tmp_path,
147
- assertion_dir=assertion_dir,
148
- prompts_path=assertion_dir / "prompts",
149
- checkpoint_path=assertion_dir / "checkpoint",
150
- metadata_path=assertion_dir / "metadata.json",
151
- )
152
179
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
153
- mock_load_session.return_value = (ctx, metadata)
180
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
181
+ mock_fingerprint.return_value = (review_diff, digest)
154
182
  mock_build_bundle.return_value = (b"bundle", review_diff, digest)
155
183
  MockClient.return_value.verify.return_value = VerifyResponse(
156
184
  session_id="sess-1",
@@ -166,9 +194,11 @@ def test_verify_does_not_reuse_after_failure(
166
194
 
167
195
  @patch("main.AssertionClient")
168
196
  @patch("main._build_verify_bundle")
197
+ @patch("main._compute_review_diff_fingerprint")
169
198
  @patch("main.load_existing_session")
170
199
  def test_verify_force_bypasses_reuse(
171
200
  mock_load_session,
201
+ mock_fingerprint,
172
202
  mock_build_bundle,
173
203
  MockClient,
174
204
  tmp_path: Path,
@@ -185,17 +215,9 @@ def test_verify_force_bypasses_reuse(
185
215
  "https://app.tryassertion.com/sessions/sess-1\n"
186
216
  )
187
217
 
188
- from session import SessionContext
189
-
190
- ctx = SessionContext(
191
- repo_root=tmp_path,
192
- assertion_dir=assertion_dir,
193
- prompts_path=assertion_dir / "prompts",
194
- checkpoint_path=assertion_dir / "checkpoint",
195
- metadata_path=assertion_dir / "metadata.json",
196
- )
197
218
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
198
- mock_load_session.return_value = (ctx, metadata)
219
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
220
+ mock_fingerprint.return_value = (review_diff, digest)
199
221
  mock_build_bundle.return_value = (b"bundle", review_diff, digest)
200
222
  MockClient.return_value.verify.return_value = VerifyResponse(
201
223
  session_id="sess-1",
@@ -222,17 +244,8 @@ def test_verify_status_persists_terminal_status(
222
244
  assertion_dir = tmp_path / ".assertion"
223
245
  _write_session(assertion_dir)
224
246
 
225
- from session import SessionContext
226
-
227
- ctx = SessionContext(
228
- repo_root=tmp_path,
229
- assertion_dir=assertion_dir,
230
- prompts_path=assertion_dir / "prompts",
231
- checkpoint_path=assertion_dir / "checkpoint",
232
- metadata_path=assertion_dir / "metadata.json",
233
- )
234
247
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
235
- mock_load_session.return_value = (ctx, metadata)
248
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
236
249
  mock_load_session_id.return_value = "sess-1"
237
250
  MockClient.return_value.status.return_value = StatusResponse(
238
251
  status=SessionStatus.SUCCEEDED,
@@ -249,9 +262,11 @@ def test_verify_status_persists_terminal_status(
249
262
 
250
263
  @patch("main.AssertionClient")
251
264
  @patch("main._build_verify_bundle")
265
+ @patch("main._compute_review_diff_fingerprint")
252
266
  @patch("main.load_existing_session")
253
267
  def test_verify_reuse_json_output(
254
268
  mock_load_session,
269
+ mock_fingerprint,
255
270
  mock_build_bundle,
256
271
  MockClient,
257
272
  tmp_path: Path,
@@ -268,17 +283,9 @@ def test_verify_reuse_json_output(
268
283
  "https://app.tryassertion.com/sessions/sess-1\n"
269
284
  )
270
285
 
271
- from session import SessionContext
272
-
273
- ctx = SessionContext(
274
- repo_root=tmp_path,
275
- assertion_dir=assertion_dir,
276
- prompts_path=assertion_dir / "prompts",
277
- checkpoint_path=assertion_dir / "checkpoint",
278
- metadata_path=assertion_dir / "metadata.json",
279
- )
280
286
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
281
- mock_load_session.return_value = (ctx, metadata)
287
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
288
+ mock_fingerprint.return_value = (review_diff, digest)
282
289
  mock_build_bundle.return_value = (b"bundle", review_diff, digest)
283
290
 
284
291
  monkeypatch.chdir(tmp_path)
@@ -289,13 +296,16 @@ def test_verify_reuse_json_output(
289
296
  assert payload["reused"] is True
290
297
  assert payload["status"] == "succeeded"
291
298
  MockClient.return_value.verify.assert_not_called()
299
+ mock_build_bundle.assert_not_called()
292
300
 
293
301
 
294
302
  @patch("main.AssertionClient")
295
303
  @patch("main._build_verify_bundle")
304
+ @patch("main._compute_review_diff_fingerprint")
296
305
  @patch("main.load_existing_session")
297
306
  def test_verify_missing_session_id_falls_through_to_submit(
298
307
  mock_load_session,
308
+ mock_fingerprint,
299
309
  mock_build_bundle,
300
310
  MockClient,
301
311
  tmp_path: Path,
@@ -308,17 +318,9 @@ def test_verify_missing_session_id_falls_through_to_submit(
308
318
  save_review_diff_hash(assertion_dir, digest)
309
319
  save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
310
320
 
311
- from session import SessionContext
312
-
313
- ctx = SessionContext(
314
- repo_root=tmp_path,
315
- assertion_dir=assertion_dir,
316
- prompts_path=assertion_dir / "prompts",
317
- checkpoint_path=assertion_dir / "checkpoint",
318
- metadata_path=assertion_dir / "metadata.json",
319
- )
320
321
  metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
321
- mock_load_session.return_value = (ctx, metadata)
322
+ mock_load_session.return_value = (_session_ctx(assertion_dir, tmp_path), metadata)
323
+ mock_fingerprint.return_value = (review_diff, digest)
322
324
  mock_build_bundle.return_value = (b"bundle", review_diff, digest)
323
325
  MockClient.return_value.verify.return_value = VerifyResponse(
324
326
  session_id="sess-1",
@@ -137,6 +137,51 @@ def test_verify_end_to_end_git_flow_submits_bundle(MockClient, tmp_path: Path, m
137
137
  assert read_verify_last_status(tmp_path / ".assertion") == SessionStatus.RUNNING.value
138
138
 
139
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
+
140
185
  @patch("main.AssertionClient")
141
186
  def test_verify_reuse_after_status_succeeded(MockClient, tmp_path: Path, monkeypatch) -> None:
142
187
  _setup_branching_repo(tmp_path)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes