claude-dev-env 8.43.5 → 8.43.6

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 (32) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.test.mjs +1 -1
  6. package/docs/second-claude-account.md +11 -13
  7. package/package.json +1 -1
  8. package/scripts/_code_review_test_support.py +29 -39
  9. package/scripts/claude_account_worker.py +46 -297
  10. package/scripts/claude_account_worker_process.py +18 -154
  11. package/scripts/claude_account_worker_report.py +8 -4
  12. package/scripts/claude_chain_usage.py +7 -271
  13. package/scripts/codec_forwarding_test_support.py +11 -65
  14. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  15. package/scripts/invoke_code_review.py +34 -56
  16. package/scripts/resolve_worker_spawn.py +40 -72
  17. package/scripts/test_account_broker_guard.py +0 -4
  18. package/scripts/test_claude_account_worker.py +105 -350
  19. package/scripts/test_claude_chain_usage.py +50 -510
  20. package/scripts/test_dispatcher_profile_import.py +5 -9
  21. package/scripts/test_invoke_code_review.py +7 -6
  22. package/scripts/test_invoke_code_review_chain.py +26 -0
  23. package/scripts/test_invoke_code_review_cli.py +8 -8
  24. package/scripts/test_invoke_code_review_codec.py +3 -11
  25. package/scripts/test_invoke_code_review_contract.py +5 -1
  26. package/scripts/test_resolve_worker_spawn.py +118 -265
  27. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  28. package/scripts/tests/test_code_review_constants.py +2 -2
  29. package/scripts/claude_account_choice.py +0 -422
  30. package/scripts/claude_chain_runner.py +0 -1289
  31. package/scripts/test_claude_account_choice.py +0 -355
  32. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -1,1839 +0,0 @@
1
- """Behavioral tests for the claude fallback-chain runner."""
2
-
3
- import json
4
- import os
5
- import subprocess
6
- import sys
7
- from pathlib import Path
8
- from unittest.mock import patch
9
-
10
- import pytest
11
-
12
- _SCRIPTS_DIR = Path(__file__).resolve().parent
13
- if str(_SCRIPTS_DIR) not in sys.path:
14
- sys.path.insert(0, str(_SCRIPTS_DIR))
15
-
16
- import claude_chain_runner as runner # noqa: E402
17
- import claude_chain_usage as chain_usage # noqa: E402
18
- from claude_chain_runner import ( # noqa: E402
19
- ChainEntry,
20
- chain_subprocess_runner_lock,
21
- override_chain_subprocess_runner,
22
- default_affinity_state_path,
23
- extract_resume_session_id,
24
- load_affinity_store,
25
- lookup_affinity_command,
26
- order_entries_for_resume,
27
- record_affinity_binding,
28
- save_affinity_store_atomic,
29
- )
30
- from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
31
- AFFINITY_KEY_ALL_BINDINGS,
32
- AFFINITY_KEY_COMMAND,
33
- AFFINITY_KEY_SCHEMA_VERSION,
34
- AFFINITY_KEY_SESSION_ID,
35
- AFFINITY_MAXIMUM_ENTRIES,
36
- AFFINITY_STATE_FILENAME,
37
- AFFINITY_STATE_SCHEMA_VERSION,
38
- RESUME_SESSION_FLAG,
39
- ALL_USAGE_LIMIT_SIGNATURES,
40
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
41
- ATTEMPT_STATUS_NONZERO_EXIT,
42
- ATTEMPT_STATUS_SERVED,
43
- ATTEMPT_STATUS_TIMEOUT,
44
- ATTEMPT_STATUS_USAGE_LIMITED,
45
- ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
46
- ATTEMPT_SUMMARY_JOIN_SEPARATOR,
47
- CHAIN_ADVISOR_BLOCKED_EXIT_CODE,
48
- CHAIN_CONFIG_ERROR_EXIT_CODE,
49
- CHAIN_EXHAUSTED_EXIT_CODE,
50
- CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
51
- CLAUDE_HOME_SUBDIRECTORY,
52
- CLI_ARGUMENTS_SEPARATOR,
53
- CLI_ROUTING_MODE_FLAG,
54
- CLI_TIMEOUT_FLAG,
55
- CODEC_ERROR_STRATEGY,
56
- CONFIG_CHAIN_EMPTY_REASON,
57
- CONFIG_CHAIN_KEY,
58
- CONFIG_CHAIN_NOT_LIST_REASON,
59
- CONFIG_COMMAND_KEY,
60
- CONFIG_ENTRY_COMMAND_MISSING_REASON,
61
- CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
62
- CONFIG_ENTRY_NOT_OBJECT_REASON,
63
- CONFIG_EXTRA_ARGS_KEY,
64
- CONFIG_FILENAME,
65
- CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE,
66
- CONFIG_MALFORMED_MESSAGE_TEMPLATE,
67
- CONFIG_MISSING_MESSAGE_TEMPLATE,
68
- CONFIG_NOT_OBJECT_REASON,
69
- CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
70
- DEFAULT_TIMEOUT_SECONDS,
71
- EXAMPLE_CONFIG_FILENAME,
72
- NO_COMPLETED_PROCESS_RETURN_CODE,
73
- ROUTING_MODE_ORDERED_ACCOUNT,
74
- SESSION_ID_JSON_KEY,
75
- TERMINAL_STATUS_ADVISOR_BLOCKED,
76
- TERMINAL_STATUS_SERVED,
77
- TERMINAL_STATUS_TIMEOUT,
78
- UTF8_ENCODING,
79
- )
80
-
81
- _LARGE_CAPTURE_BYTE_COUNT = 400_000
82
- _LARGE_CAPTURE_MARKER = "X"
83
-
84
-
85
- def test_chain_subprocess_runner_lock_is_shared() -> None:
86
- first_lock = chain_subprocess_runner_lock()
87
- second_lock = chain_subprocess_runner_lock()
88
-
89
- assert first_lock is second_lock
90
-
91
-
92
- def test_override_chain_subprocess_runner_restores_previous_runner() -> None:
93
- previous_runner = runner.chain_subprocess_runner
94
-
95
- def replacement_runner(
96
- all_invocation_tokens: list[str],
97
- **all_keywords: object,
98
- ) -> subprocess.CompletedProcess[str]:
99
- del all_invocation_tokens, all_keywords
100
- return subprocess.CompletedProcess([], 0, "", "")
101
-
102
- with override_chain_subprocess_runner(replacement_runner) as active_runner:
103
- assert active_runner is previous_runner
104
- assert runner.chain_subprocess_runner is replacement_runner
105
-
106
- assert runner.chain_subprocess_runner is previous_runner
107
-
108
-
109
- _STDIN_ECHO_PAYLOAD = "charter body for spool path"
110
- _UNDECODABLE_STDOUT_BYTES = b"ok \x90 end"
111
- _DECODED_UNDECODABLE_STDOUT = "ok \ufffd end"
112
- _CRLF_CHILD_STDOUT_BYTES = b"a\r\nb\rc\n"
113
- _CRLF_CHILD_STDOUT_NORMALIZED = "a\nb\nc\n"
114
-
115
- _A_SIGNATURE = ALL_USAGE_LIMIT_SIGNATURES[0]
116
- _PROMPT_ARGUMENTS = ["-p", "hello"]
117
- _EQUAL_WEEKLY_REMAINING_PERCENT = 50.0
118
- _HIGH_WEEKLY_REMAINING_PERCENT = 90.0
119
- _MID_WEEKLY_REMAINING_PERCENT = 50.0
120
- _LOW_WEEKLY_REMAINING_PERCENT = 10.0
121
- _PRIMARY_LAUNCHER = "primary-launcher"
122
- _SECONDARY_LAUNCHER = "secondary-launcher"
123
- _BOUND_SESSION_ID = "session-bound-abc-123"
124
- _AUTHENTICATION_ERROR_STDERR = "authentication_error: invalid API key"
125
- _GENERIC_PROCESS_ERROR_STDERR = "unknown flag: --not-a-real-flag"
126
- _JSON_BIND_STDOUT_WITH_SESSION = (
127
- f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
128
- f'"result":"ENDORSE"}}\n'
129
- )
130
-
131
-
132
- def _completed(command, returncode, stdout="", stderr=""):
133
- return subprocess.CompletedProcess(
134
- args=[command], returncode=returncode, stdout=stdout, stderr=stderr
135
- )
136
-
137
-
138
- def _entry(command, extra_args=None):
139
- return {
140
- CONFIG_COMMAND_KEY: command,
141
- CONFIG_EXTRA_ARGS_KEY: extra_args if extra_args is not None else [],
142
- }
143
-
144
-
145
- def _write_chain_config(tmp_path, chain_entries):
146
- config_file = tmp_path / CONFIG_FILENAME
147
- config_file.write_text(
148
- json.dumps({CONFIG_CHAIN_KEY: chain_entries}), encoding=UTF8_ENCODING
149
- )
150
- return config_file
151
-
152
-
153
- def _config_order_usage_reporter(
154
- *, config_path: Path
155
- ) -> list[chain_usage.AccountUsageReport]:
156
- all_entries = runner.load_chain(config_path)
157
- return [
158
- chain_usage.AccountUsageReport(
159
- command=each_entry.command,
160
- weekly_remaining_percent=_EQUAL_WEEKLY_REMAINING_PERCENT,
161
- )
162
- for each_entry in all_entries
163
- ]
164
-
165
-
166
- def _usage_reporter_from_remaining(
167
- remaining_percent_by_command: dict[str, float | None],
168
- ):
169
- call_count = {"count": 0}
170
-
171
- def active_reporter(
172
- *, config_path: Path
173
- ) -> list[chain_usage.AccountUsageReport]:
174
- call_count["count"] += 1
175
- all_entries = runner.load_chain(config_path)
176
- return [
177
- chain_usage.AccountUsageReport(
178
- command=each_entry.command,
179
- weekly_remaining_percent=remaining_percent_by_command[
180
- each_entry.command
181
- ],
182
- )
183
- for each_entry in all_entries
184
- ]
185
-
186
- active_reporter.call_count = call_count
187
- return active_reporter
188
-
189
-
190
- class _Recorder:
191
- def __init__(self, behavior_by_command):
192
- self.behavior_by_command = behavior_by_command
193
- self.invocations = []
194
- self.timeouts = []
195
- self.all_keyword_arguments = []
196
-
197
- def __call__(self, invocation, **keyword_arguments):
198
- self.invocations.append(invocation)
199
- self.timeouts.append(keyword_arguments.get("timeout"))
200
- self.all_keyword_arguments.append(keyword_arguments)
201
- behavior = self.behavior_by_command[invocation[0]]
202
- if isinstance(behavior, BaseException):
203
- raise behavior
204
- return behavior
205
-
206
-
207
- class _TtyStdin:
208
- def isatty(self) -> bool:
209
- return True
210
-
211
-
212
- def _install_tty_stdin(monkeypatch):
213
- monkeypatch.setattr(runner.sys, "stdin", _TtyStdin())
214
-
215
-
216
- def _install(
217
- monkeypatch,
218
- config_file,
219
- behavior_by_command,
220
- *,
221
- weekly_usage_reporter=None,
222
- ):
223
- recorder = _Recorder(behavior_by_command)
224
- monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
225
- monkeypatch.setattr(runner, "chain_subprocess_runner", recorder)
226
- active_reporter = (
227
- weekly_usage_reporter
228
- if weekly_usage_reporter is not None
229
- else _config_order_usage_reporter
230
- )
231
- monkeypatch.setattr(runner, "chain_weekly_usage_reporter", active_reporter)
232
- affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
233
- monkeypatch.setattr(
234
- runner,
235
- "default_affinity_state_path",
236
- lambda _home, _affinity_path=affinity_path: _affinity_path,
237
- )
238
- _install_tty_stdin(monkeypatch)
239
- return recorder
240
-
241
-
242
- def test_highest_weekly_remaining_is_tried_first(
243
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
244
- ) -> None:
245
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
246
- usage_reporter = _usage_reporter_from_remaining(
247
- {
248
- "claude": _LOW_WEEKLY_REMAINING_PERCENT,
249
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
250
- }
251
- )
252
- recorder = _install(
253
- monkeypatch,
254
- config_file,
255
- {
256
- "claude": _completed("claude", 0, stdout="from-claude"),
257
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
258
- },
259
- weekly_usage_reporter=usage_reporter,
260
- )
261
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
262
- assert chain_result.served_command == "claude-profile-c"
263
- assert chain_result.returncode == 0
264
- assert chain_result.stdout == "from-profile-c"
265
- assert recorder.invocations[0][0] == "claude-profile-c"
266
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
267
- "claude-profile-c"
268
- ]
269
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_SERVED
270
-
271
-
272
- def test_usage_limit_failsover_remaining_ranked_accounts(
273
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
274
- ) -> None:
275
- config_file = _write_chain_config(
276
- tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
277
- )
278
- usage_reporter = _usage_reporter_from_remaining(
279
- {
280
- "claude": _MID_WEEKLY_REMAINING_PERCENT,
281
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
282
- "claude-profile-a": _LOW_WEEKLY_REMAINING_PERCENT,
283
- }
284
- )
285
- _install(
286
- monkeypatch,
287
- config_file,
288
- {
289
- "claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
290
- "claude": _completed("claude", 0, stdout="ok"),
291
- "claude-profile-a": _completed("claude-profile-a", 0, stdout="should not run"),
292
- },
293
- weekly_usage_reporter=usage_reporter,
294
- )
295
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
296
- assert chain_result.served_command == "claude"
297
- assert chain_result.returncode == 0
298
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
299
- "claude-profile-c",
300
- "claude",
301
- ]
302
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
303
- assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
304
-
305
-
306
- def test_non_usage_error_on_highest_ranked_stops_without_rest(
307
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
308
- ) -> None:
309
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
310
- usage_reporter = _usage_reporter_from_remaining(
311
- {
312
- "claude": _LOW_WEEKLY_REMAINING_PERCENT,
313
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
314
- }
315
- )
316
- _install(
317
- monkeypatch,
318
- config_file,
319
- {
320
- "claude-profile-c": _completed("claude-profile-c", 2, stderr="unknown flag"),
321
- "claude": _completed("claude", 0, stdout="should not run"),
322
- },
323
- weekly_usage_reporter=usage_reporter,
324
- )
325
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
326
- assert chain_result.served_command == "claude-profile-c"
327
- assert chain_result.returncode == 2
328
- assert len(chain_result.attempts) == 1
329
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
330
-
331
-
332
- def test_weekly_usage_probe_runs_once_per_run_claude(
333
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
334
- ) -> None:
335
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
336
- usage_reporter = _usage_reporter_from_remaining(
337
- {
338
- "claude": _HIGH_WEEKLY_REMAINING_PERCENT,
339
- "claude-profile-c": _LOW_WEEKLY_REMAINING_PERCENT,
340
- }
341
- )
342
- _install(
343
- monkeypatch,
344
- config_file,
345
- {
346
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
347
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="ok"),
348
- },
349
- weekly_usage_reporter=usage_reporter,
350
- )
351
- runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
352
- assert usage_reporter.call_count["count"] == 1
353
-
354
-
355
- def test_usage_reporter_import_failure_falls_back_to_config_order(
356
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
357
- ) -> None:
358
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
359
-
360
- def broken_usage_reporter(
361
- *, config_path: Path
362
- ) -> list[chain_usage.AccountUsageReport]:
363
- raise ImportError("claude_chain_usage unavailable")
364
-
365
- recorder = _install(
366
- monkeypatch,
367
- config_file,
368
- {
369
- "claude": _completed("claude", 0, stdout="from-claude"),
370
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
371
- },
372
- weekly_usage_reporter=broken_usage_reporter,
373
- )
374
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
375
- assert chain_result.served_command == "claude"
376
- assert chain_result.returncode == 0
377
- assert chain_result.stdout == "from-claude"
378
- assert recorder.invocations[0][0] == "claude"
379
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
380
- "claude"
381
- ]
382
-
383
-
384
- def test_missing_high_remaining_binary_falls_through_to_lower_remaining(
385
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
386
- ) -> None:
387
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
388
- usage_reporter = _usage_reporter_from_remaining(
389
- {
390
- "claude": _LOW_WEEKLY_REMAINING_PERCENT,
391
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
392
- }
393
- )
394
- _install(
395
- monkeypatch,
396
- config_file,
397
- {
398
- "claude-profile-c": FileNotFoundError(),
399
- "claude": _completed("claude", 0, stdout="from-claude"),
400
- },
401
- weekly_usage_reporter=usage_reporter,
402
- )
403
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
404
- assert chain_result.served_command == "claude"
405
- assert chain_result.returncode == 0
406
- assert chain_result.stdout == "from-claude"
407
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
408
- "claude-profile-c",
409
- "claude",
410
- ]
411
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
412
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
413
- ATTEMPT_STATUS_SERVED,
414
- ]
415
-
416
-
417
- def test_all_missing_binaries_exhausts_chain(
418
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
419
- ) -> None:
420
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
421
- usage_reporter = _usage_reporter_from_remaining(
422
- {
423
- "claude": _LOW_WEEKLY_REMAINING_PERCENT,
424
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
425
- }
426
- )
427
- _install(
428
- monkeypatch,
429
- config_file,
430
- {
431
- "claude-profile-c": FileNotFoundError(),
432
- "claude": FileNotFoundError(),
433
- },
434
- weekly_usage_reporter=usage_reporter,
435
- )
436
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
437
- assert chain_result.served_command is None
438
- assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
439
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
440
- "claude-profile-c",
441
- "claude",
442
- ]
443
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
444
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
445
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
446
- ]
447
-
448
-
449
- def test_missing_later_ranked_binary_is_skipped(
450
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
451
- ) -> None:
452
- config_file = _write_chain_config(
453
- tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
454
- )
455
- usage_reporter = _usage_reporter_from_remaining(
456
- {
457
- "claude": _MID_WEEKLY_REMAINING_PERCENT,
458
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
459
- "claude-profile-a": _LOW_WEEKLY_REMAINING_PERCENT,
460
- }
461
- )
462
- _install(
463
- monkeypatch,
464
- config_file,
465
- {
466
- "claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
467
- "claude": FileNotFoundError(),
468
- "claude-profile-a": _completed("claude-profile-a", 0, stdout="done"),
469
- },
470
- weekly_usage_reporter=usage_reporter,
471
- )
472
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
473
- assert chain_result.served_command == "claude-profile-a"
474
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
475
- "claude-profile-c",
476
- "claude",
477
- "claude-profile-a",
478
- ]
479
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
480
- ATTEMPT_STATUS_USAGE_LIMITED,
481
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
482
- ATTEMPT_STATUS_SERVED,
483
- ]
484
-
485
-
486
- def test_ranked_walk_preserves_extra_args_for_mapped_entry(
487
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
488
- ) -> None:
489
- config_file = _write_chain_config(
490
- tmp_path,
491
- [
492
- _entry("claude", extra_args=["--account", "primary"]),
493
- _entry("claude-profile-c", extra_args=["--account", "profile-c"]),
494
- ],
495
- )
496
- usage_reporter = _usage_reporter_from_remaining(
497
- {
498
- "claude": _LOW_WEEKLY_REMAINING_PERCENT,
499
- "claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
500
- }
501
- )
502
- recorder = _install(
503
- monkeypatch,
504
- config_file,
505
- {"claude-profile-c": _completed("claude-profile-c", 0)},
506
- weekly_usage_reporter=usage_reporter,
507
- )
508
- runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
509
- assert recorder.invocations[0] == [
510
- "claude-profile-c",
511
- "-p",
512
- "hello",
513
- "--account",
514
- "profile-c",
515
- ]
516
-
517
-
518
- def test_duplicate_command_entries_are_both_walked(
519
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
520
- ) -> None:
521
- config_file = _write_chain_config(
522
- tmp_path,
523
- [
524
- _entry("claude", extra_args=["--account", "primary"]),
525
- _entry("claude", extra_args=["--account", "secondary"]),
526
- ],
527
- )
528
- usage_reporter = _usage_reporter_from_remaining(
529
- {"claude": _HIGH_WEEKLY_REMAINING_PERCENT}
530
- )
531
- recorder = _install(
532
- monkeypatch,
533
- config_file,
534
- {"claude": _completed("claude", 1, stderr=_A_SIGNATURE)},
535
- weekly_usage_reporter=usage_reporter,
536
- )
537
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
538
- assert [each_invocation[-1] for each_invocation in recorder.invocations] == [
539
- "primary",
540
- "secondary",
541
- ]
542
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
543
- ATTEMPT_STATUS_USAGE_LIMITED,
544
- ATTEMPT_STATUS_USAGE_LIMITED,
545
- ]
546
-
547
-
548
- def test_entry_absent_from_usage_report_is_still_walked(
549
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
550
- ) -> None:
551
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
552
-
553
- def partial_usage_reporter(
554
- *, config_path: Path
555
- ) -> list[chain_usage.AccountUsageReport]:
556
- return [
557
- chain_usage.AccountUsageReport(
558
- command="claude-profile-c",
559
- weekly_remaining_percent=_HIGH_WEEKLY_REMAINING_PERCENT,
560
- )
561
- ]
562
-
563
- _install(
564
- monkeypatch,
565
- config_file,
566
- {
567
- "claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
568
- "claude": _completed("claude", 0, stdout="ok"),
569
- },
570
- weekly_usage_reporter=partial_usage_reporter,
571
- )
572
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
573
- assert chain_result.served_command == "claude"
574
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
575
- "claude-profile-c",
576
- "claude",
577
- ]
578
-
579
-
580
- def test_usage_limited_primary_falls_over_to_second(
581
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
582
- ) -> None:
583
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
584
- _install(
585
- monkeypatch,
586
- config_file,
587
- {
588
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
589
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="ok"),
590
- },
591
- )
592
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
593
- assert chain_result.served_command == "claude-profile-c"
594
- assert chain_result.returncode == 0
595
- assert chain_result.stdout == "ok"
596
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
597
- "claude",
598
- "claude-profile-c",
599
- ]
600
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
601
- assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
602
-
603
-
604
- def test_nonzero_exit_without_signature_does_not_fall_over(
605
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
606
- ) -> None:
607
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
608
- _install(
609
- monkeypatch,
610
- config_file,
611
- {
612
- "claude": _completed("claude", 2, stderr="unknown flag"),
613
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="should not run"),
614
- },
615
- )
616
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
617
- assert chain_result.served_command == "claude"
618
- assert chain_result.returncode == 2
619
- assert len(chain_result.attempts) == 1
620
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
621
-
622
-
623
- def test_timeout_on_primary_does_not_fall_over(
624
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
625
- ) -> None:
626
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
627
- _install(
628
- monkeypatch,
629
- config_file,
630
- {
631
- "claude": subprocess.TimeoutExpired(cmd=["claude"], timeout=5),
632
- "claude-profile-c": _completed("claude-profile-c", 0),
633
- },
634
- )
635
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
636
- assert chain_result.served_command is None
637
- assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
638
- assert chain_result.terminal_status == TERMINAL_STATUS_TIMEOUT
639
- assert len(chain_result.attempts) == 1
640
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
641
-
642
-
643
- def test_missing_primary_binary_falls_through_to_next(
644
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
645
- ) -> None:
646
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
647
- _install(
648
- monkeypatch,
649
- config_file,
650
- {
651
- "claude": FileNotFoundError(),
652
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
653
- },
654
- )
655
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
656
- assert chain_result.served_command == "claude-profile-c"
657
- assert chain_result.returncode == 0
658
- assert chain_result.stdout == "from-profile-c"
659
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
660
- "claude",
661
- "claude-profile-c",
662
- ]
663
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
664
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
665
- ATTEMPT_STATUS_SERVED,
666
- ]
667
-
668
-
669
- def test_missing_fallback_binary_is_skipped_and_walk_continues(
670
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
671
- ) -> None:
672
- config_file = _write_chain_config(
673
- tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
674
- )
675
- _install(
676
- monkeypatch,
677
- config_file,
678
- {
679
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
680
- "claude-profile-c": FileNotFoundError(),
681
- "claude-profile-a": _completed("claude-profile-a", 0, stdout="done"),
682
- },
683
- )
684
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
685
- assert chain_result.served_command == "claude-profile-a"
686
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
687
- ATTEMPT_STATUS_USAGE_LIMITED,
688
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
689
- ATTEMPT_STATUS_SERVED,
690
- ]
691
-
692
-
693
- def test_zero_exit_mentioning_usage_limit_is_success(
694
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
695
- ) -> None:
696
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
697
- _install(
698
- monkeypatch,
699
- config_file,
700
- {
701
- "claude": _completed("claude", 0, stdout=f"note: {_A_SIGNATURE} earlier"),
702
- "claude-profile-c": _completed("claude-profile-c", 0),
703
- },
704
- )
705
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
706
- assert chain_result.served_command == "claude"
707
- assert chain_result.returncode == 0
708
- assert len(chain_result.attempts) == 1
709
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_SERVED
710
-
711
-
712
- def test_exhausted_chain_records_every_attempt(
713
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
714
- ) -> None:
715
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
716
- _install(
717
- monkeypatch,
718
- config_file,
719
- {
720
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
721
- "claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
722
- },
723
- )
724
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
725
- assert chain_result.served_command is None
726
- assert chain_result.returncode == 1
727
- assert [each_attempt.status for each_attempt in chain_result.attempts] == [
728
- ATTEMPT_STATUS_USAGE_LIMITED,
729
- ATTEMPT_STATUS_USAGE_LIMITED,
730
- ]
731
-
732
-
733
- def test_reordering_config_changes_walk_order(
734
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
735
- ) -> None:
736
- behavior_by_command = {
737
- "claude": _completed("claude", 0, stdout="from-claude"),
738
- "claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
739
- }
740
- first_config = tmp_path / "first"
741
- first_config.mkdir()
742
- config_a = _write_chain_config(
743
- first_config, [_entry("claude"), _entry("claude-profile-c")]
744
- )
745
- _install(monkeypatch, config_a, behavior_by_command)
746
- result_a = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
747
- assert result_a.served_command == "claude"
748
-
749
- second_config = tmp_path / "second"
750
- second_config.mkdir()
751
- config_b = _write_chain_config(
752
- second_config, [_entry("claude-profile-c"), _entry("claude")]
753
- )
754
- _install(monkeypatch, config_b, behavior_by_command)
755
- result_b = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
756
- assert result_b.served_command == "claude-profile-c"
757
-
758
-
759
- def test_extra_args_are_appended_to_invocation(
760
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
761
- ) -> None:
762
- config_file = _write_chain_config(
763
- tmp_path, [_entry("claude", extra_args=["--account", "profile-c"])]
764
- )
765
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
766
- runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
767
- assert recorder.invocations[0] == ["claude", "-p", "hello", "--account", "profile-c"]
768
-
769
-
770
- def test_run_claude_forwards_stdin_text_to_subprocess(
771
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
772
- ) -> None:
773
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
774
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
775
- runner.run_claude(
776
- _PROMPT_ARGUMENTS, timeout_seconds=5, stdin_text="charter body"
777
- )
778
- assert recorder.all_keyword_arguments[0]["input"] == "charter body"
779
-
780
-
781
- def test_run_claude_passes_none_input_when_stdin_text_omitted(
782
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
783
- ) -> None:
784
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
785
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
786
- runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
787
- assert recorder.all_keyword_arguments[0].get("input") is None
788
-
789
-
790
- def test_cli_forwards_piped_stdin_to_invocation(
791
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
792
- ) -> None:
793
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
794
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
795
-
796
- class _PipedStdin:
797
- def isatty(self) -> bool:
798
- return False
799
-
800
- def read(self) -> str:
801
- return "charter body"
802
-
803
- monkeypatch.setattr(runner.sys, "stdin", _PipedStdin())
804
- exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
805
- assert exit_code == 0
806
- assert recorder.all_keyword_arguments[0]["input"] == "charter body"
807
-
808
-
809
- def test_signature_matching_is_case_insensitive(
810
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
811
- ) -> None:
812
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
813
- _install(
814
- monkeypatch,
815
- config_file,
816
- {
817
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE.upper()),
818
- "claude-profile-c": _completed("claude-profile-c", 0),
819
- },
820
- )
821
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
822
- assert chain_result.served_command == "claude-profile-c"
823
-
824
-
825
- def test_cli_passthrough_builds_argument_list_and_timeout(
826
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
827
- ) -> None:
828
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
829
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
830
- exit_code = runner.main(
831
- [CLI_TIMEOUT_FLAG, "9", CLI_ARGUMENTS_SEPARATOR, "-p", "hi"]
832
- )
833
- assert exit_code == 0
834
- assert recorder.invocations[0] == ["claude", "-p", "hi"]
835
- assert recorder.timeouts[0] == 9
836
-
837
-
838
- def test_cli_uses_default_timeout_when_flag_omitted(
839
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
840
- ) -> None:
841
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
842
- recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
843
- runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
844
- assert recorder.timeouts[0] == DEFAULT_TIMEOUT_SECONDS
845
-
846
-
847
- def test_cli_served_nonzero_exit_is_passed_through(
848
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
849
- ) -> None:
850
- config_file = _write_chain_config(tmp_path, [_entry("claude")])
851
- _install(
852
- monkeypatch,
853
- config_file,
854
- {"claude": _completed("claude", 7, stdout="out", stderr="err")},
855
- )
856
- exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
857
- captured = capsys.readouterr()
858
- assert exit_code == 7
859
- assert captured.out == "out"
860
- assert "err" in captured.err
861
-
862
-
863
- def test_cli_exhausted_chain_exits_nonzero_with_attempt_summary(
864
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
865
- ) -> None:
866
- config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
867
- _install(
868
- monkeypatch,
869
- config_file,
870
- {
871
- "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
872
- "claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
873
- },
874
- )
875
- exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
876
- captured = capsys.readouterr()
877
- assert exit_code == CHAIN_EXHAUSTED_EXIT_CODE
878
- expected_summary = ATTEMPT_SUMMARY_JOIN_SEPARATOR.join(
879
- ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
880
- command=each_command, status=ATTEMPT_STATUS_USAGE_LIMITED
881
- )
882
- for each_command in ("claude", "claude-profile-c")
883
- )
884
- assert (
885
- CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=expected_summary)
886
- in captured.err
887
- )
888
-
889
-
890
- def test_cli_missing_config_exits_config_error(
891
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
892
- ) -> None:
893
- missing_config = tmp_path / CONFIG_FILENAME
894
- monkeypatch.setattr(runner, "chain_config_path", lambda: missing_config)
895
- _install_tty_stdin(monkeypatch)
896
- exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
897
- captured = capsys.readouterr()
898
- assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
899
- assert EXAMPLE_CONFIG_FILENAME in captured.err
900
-
901
-
902
- def test_missing_config_raises_naming_path_and_example(tmp_path: Path) -> None:
903
- missing_config = tmp_path / CONFIG_FILENAME
904
- with pytest.raises(runner.ChainConfigurationError) as raised:
905
- runner.load_chain(missing_config)
906
- assert str(raised.value) == CONFIG_MISSING_MESSAGE_TEMPLATE.format(
907
- config_path=missing_config, example_filename=EXAMPLE_CONFIG_FILENAME
908
- )
909
-
910
-
911
- def test_malformed_json_raises_configuration_error(tmp_path: Path) -> None:
912
- config_file = tmp_path / CONFIG_FILENAME
913
- config_file.write_text("{ not valid json", encoding=UTF8_ENCODING)
914
- with pytest.raises(runner.ChainConfigurationError) as raised:
915
- runner.load_chain(config_file)
916
- malformed_prefix = CONFIG_MALFORMED_MESSAGE_TEMPLATE.split("{config_path}")[0]
917
- assert malformed_prefix in str(raised.value)
918
- assert EXAMPLE_CONFIG_FILENAME in str(raised.value)
919
-
920
-
921
- def test_unreadable_config_raises_configuration_error(tmp_path: Path) -> None:
922
- config_file = tmp_path / CONFIG_FILENAME
923
- config_file.write_text("{}", encoding=UTF8_ENCODING)
924
- with patch.object(Path, "read_text", side_effect=OSError("locked")):
925
- with pytest.raises(runner.ChainConfigurationError) as raised:
926
- runner.load_chain(config_file)
927
- unreadable_prefix = CONFIG_UNREADABLE_MESSAGE_TEMPLATE.split("{config_path}")[0]
928
- assert unreadable_prefix in str(raised.value)
929
-
930
-
931
- def test_top_level_not_object_raises_invalid_shape(tmp_path: Path) -> None:
932
- config_file = tmp_path / CONFIG_FILENAME
933
- config_file.write_text(json.dumps(["claude"]), encoding=UTF8_ENCODING)
934
- with pytest.raises(runner.ChainConfigurationError) as raised:
935
- runner.load_chain(config_file)
936
- assert str(raised.value) == CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE.format(
937
- config_path=config_file,
938
- reason=CONFIG_NOT_OBJECT_REASON,
939
- example_filename=EXAMPLE_CONFIG_FILENAME,
940
- )
941
-
942
-
943
- def test_chain_key_not_list_raises_invalid_shape(tmp_path: Path) -> None:
944
- config_file = tmp_path / CONFIG_FILENAME
945
- config_file.write_text(
946
- json.dumps({CONFIG_CHAIN_KEY: "claude"}), encoding=UTF8_ENCODING
947
- )
948
- with pytest.raises(runner.ChainConfigurationError) as raised:
949
- runner.load_chain(config_file)
950
- assert CONFIG_CHAIN_NOT_LIST_REASON in str(raised.value)
951
-
952
-
953
- def test_empty_chain_raises_invalid_shape(tmp_path: Path) -> None:
954
- config_file = _write_chain_config(tmp_path, [])
955
- with pytest.raises(runner.ChainConfigurationError) as raised:
956
- runner.load_chain(config_file)
957
- assert CONFIG_CHAIN_EMPTY_REASON in str(raised.value)
958
-
959
-
960
- def test_entry_not_object_raises_invalid_shape(tmp_path: Path) -> None:
961
- config_file = _write_chain_config(tmp_path, ["claude"])
962
- with pytest.raises(runner.ChainConfigurationError) as raised:
963
- runner.load_chain(config_file)
964
- assert CONFIG_ENTRY_NOT_OBJECT_REASON in str(raised.value)
965
-
966
-
967
- def test_entry_without_command_raises_invalid_shape(tmp_path: Path) -> None:
968
- config_file = _write_chain_config(tmp_path, [{CONFIG_EXTRA_ARGS_KEY: []}])
969
- with pytest.raises(runner.ChainConfigurationError) as raised:
970
- runner.load_chain(config_file)
971
- assert CONFIG_ENTRY_COMMAND_MISSING_REASON in str(raised.value)
972
-
973
-
974
- def test_entry_with_non_list_extra_args_raises_invalid_shape(tmp_path: Path) -> None:
975
- config_file = _write_chain_config(
976
- tmp_path, [{CONFIG_COMMAND_KEY: "claude", CONFIG_EXTRA_ARGS_KEY: "nope"}]
977
- )
978
- with pytest.raises(runner.ChainConfigurationError) as raised:
979
- runner.load_chain(config_file)
980
- assert CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON in str(raised.value)
981
-
982
-
983
- def test_extra_args_default_to_empty_when_omitted(tmp_path: Path) -> None:
984
- config_file = _write_chain_config(tmp_path, [{CONFIG_COMMAND_KEY: "claude"}])
985
- all_entries = runner.load_chain(config_file)
986
- assert all_entries[0].command == "claude"
987
- assert all_entries[0].extra_args == ()
988
-
989
-
990
- def test_load_chain_parses_command_and_extra_args(tmp_path: Path) -> None:
991
- config_file = _write_chain_config(
992
- tmp_path, [_entry("claude", extra_args=["--account", "profile-c"])]
993
- )
994
- all_entries = runner.load_chain(config_file)
995
- assert all_entries == [
996
- runner.ChainEntry(command="claude", extra_args=("--account", "profile-c"))
997
- ]
998
-
999
-
1000
- def test_chain_config_path_points_at_home_config() -> None:
1001
- config_path = runner.chain_config_path()
1002
- assert config_path.name == CONFIG_FILENAME
1003
- assert CLAUDE_HOME_SUBDIRECTORY in config_path.parts
1004
-
1005
-
1006
- def test_real_subprocess_capture_replaces_undecodable_bytes(
1007
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1008
- ) -> None:
1009
- config_file = _write_chain_config(tmp_path, [_entry(sys.executable)])
1010
- monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
1011
- monkeypatch.setattr(
1012
- runner, "chain_weekly_usage_reporter", _config_order_usage_reporter
1013
- )
1014
- child_code = 'import sys; sys.stdout.buffer.write(b"ok \\x90 end")'
1015
- chain_result = runner.run_claude(["-c", child_code], timeout_seconds=60)
1016
- assert chain_result.served_command == sys.executable
1017
- assert chain_result.returncode == 0
1018
- assert chain_result.stdout == "ok � end"
1019
-
1020
-
1021
- def test_real_subprocess_capture_preserves_utf8_text(
1022
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1023
- ) -> None:
1024
- config_file = _write_chain_config(tmp_path, [_entry(sys.executable)])
1025
- monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
1026
- monkeypatch.setattr(
1027
- runner, "chain_weekly_usage_reporter", _config_order_usage_reporter
1028
- )
1029
- child_code = 'import sys; sys.stdout.buffer.write("report ✅ done".encode("utf-8"))'
1030
- chain_result = runner.run_claude(["-c", child_code], timeout_seconds=60)
1031
- assert chain_result.served_command == sys.executable
1032
- assert chain_result.returncode == 0
1033
- assert chain_result.stdout == "report ✅ done"
1034
-
1035
-
1036
- def test_run_captured_subprocess_spools_large_stdout_and_stderr() -> None:
1037
- child_code = (
1038
- "import sys;"
1039
- f"sys.stdout.write({_LARGE_CAPTURE_MARKER!r} * {_LARGE_CAPTURE_BYTE_COUNT});"
1040
- "sys.stderr.write('err-marker');"
1041
- "sys.exit(3)"
1042
- )
1043
- completion = runner._run_captured_subprocess(
1044
- [sys.executable, "-c", child_code],
1045
- encoding=UTF8_ENCODING,
1046
- errors=CODEC_ERROR_STRATEGY,
1047
- timeout=60,
1048
- check=False,
1049
- input=None,
1050
- )
1051
- assert completion.returncode == 3
1052
- assert len(completion.stdout) == _LARGE_CAPTURE_BYTE_COUNT
1053
- assert completion.stdout == _LARGE_CAPTURE_MARKER * _LARGE_CAPTURE_BYTE_COUNT
1054
- assert completion.stderr == "err-marker"
1055
-
1056
-
1057
- def test_run_captured_subprocess_forwards_stdin_input() -> None:
1058
- child_code = "import sys; sys.stdout.write(sys.stdin.read())"
1059
- completion = runner._run_captured_subprocess(
1060
- [sys.executable, "-c", child_code],
1061
- encoding=UTF8_ENCODING,
1062
- errors=CODEC_ERROR_STRATEGY,
1063
- timeout=60,
1064
- check=False,
1065
- input=_STDIN_ECHO_PAYLOAD,
1066
- )
1067
- assert completion.returncode == 0
1068
- assert completion.stdout == _STDIN_ECHO_PAYLOAD
1069
- assert completion.stderr == ""
1070
-
1071
-
1072
- def test_run_captured_subprocess_replaces_undecodable_stdout_bytes() -> None:
1073
- child_code = (
1074
- "import sys;"
1075
- f"sys.stdout.buffer.write({_UNDECODABLE_STDOUT_BYTES!r})"
1076
- )
1077
- completion = runner._run_captured_subprocess(
1078
- [sys.executable, "-c", child_code],
1079
- encoding=UTF8_ENCODING,
1080
- errors=CODEC_ERROR_STRATEGY,
1081
- timeout=60,
1082
- check=False,
1083
- input=None,
1084
- )
1085
- assert completion.returncode == 0
1086
- assert completion.stdout == _DECODED_UNDECODABLE_STDOUT
1087
-
1088
-
1089
- def test_run_captured_subprocess_normalizes_crlf_to_lf() -> None:
1090
- """Spool decode matches subprocess text=True universal-newline translation."""
1091
- child_code = (
1092
- "import sys;"
1093
- f"sys.stdout.buffer.write({_CRLF_CHILD_STDOUT_BYTES!r})"
1094
- )
1095
- completion = runner._run_captured_subprocess(
1096
- [sys.executable, "-c", child_code],
1097
- encoding=UTF8_ENCODING,
1098
- errors=CODEC_ERROR_STRATEGY,
1099
- timeout=60,
1100
- check=False,
1101
- input=None,
1102
- )
1103
- assert completion.returncode == 0
1104
- assert completion.stdout == _CRLF_CHILD_STDOUT_NORMALIZED
1105
-
1106
-
1107
- def test_run_captured_subprocess_honors_cwd(tmp_path: Path) -> None:
1108
- child_code = "import os, sys; sys.stdout.write(os.getcwd())"
1109
- completion = runner._run_captured_subprocess(
1110
- [sys.executable, "-c", child_code],
1111
- encoding=UTF8_ENCODING,
1112
- errors=CODEC_ERROR_STRATEGY,
1113
- timeout=60,
1114
- check=False,
1115
- cwd=str(tmp_path),
1116
- )
1117
- assert completion.returncode == 0
1118
- assert Path(completion.stdout).resolve() == tmp_path.resolve()
1119
-
1120
-
1121
- def test_run_captured_subprocess_timeout_keeps_partial_stdout() -> None:
1122
- child_code = (
1123
- "import sys, time;"
1124
- "sys.stdout.write('partial-before-timeout');"
1125
- "sys.stdout.flush();"
1126
- "time.sleep(30)"
1127
- )
1128
- with pytest.raises(subprocess.TimeoutExpired) as raised:
1129
- runner._run_captured_subprocess(
1130
- [sys.executable, "-c", child_code],
1131
- encoding=UTF8_ENCODING,
1132
- errors=CODEC_ERROR_STRATEGY,
1133
- timeout=1,
1134
- check=False,
1135
- input=None,
1136
- )
1137
- assert raised.value.stdout == "partial-before-timeout"
1138
- assert isinstance(raised.value.stdout, str)
1139
-
1140
-
1141
- def test_run_captured_subprocess_reads_stdin_stream(tmp_path: Path) -> None:
1142
- prompt_path = tmp_path / "prompt_body.txt"
1143
- prompt_path.write_text(_STDIN_ECHO_PAYLOAD, encoding=UTF8_ENCODING)
1144
- child_code = "import sys; sys.stdout.write(sys.stdin.read())"
1145
- with prompt_path.open(encoding=UTF8_ENCODING) as prompt_stream:
1146
- completion = runner._run_captured_subprocess(
1147
- [sys.executable, "-c", child_code],
1148
- encoding=UTF8_ENCODING,
1149
- errors=CODEC_ERROR_STRATEGY,
1150
- timeout=60,
1151
- check=False,
1152
- stdin=prompt_stream,
1153
- )
1154
- assert completion.returncode == 0
1155
- assert completion.stdout == _STDIN_ECHO_PAYLOAD
1156
-
1157
-
1158
- def test_cli_emits_utf8_when_console_encoding_is_legacy(tmp_path: Path) -> None:
1159
- tmp_home = tmp_path / "home"
1160
- claude_directory = tmp_home / CLAUDE_HOME_SUBDIRECTORY
1161
- claude_directory.mkdir(parents=True)
1162
- config_file = claude_directory / CONFIG_FILENAME
1163
- config_file.write_text(
1164
- json.dumps({CONFIG_CHAIN_KEY: [_entry(sys.executable)]}),
1165
- encoding=UTF8_ENCODING,
1166
- )
1167
- runner_script = _SCRIPTS_DIR / "claude_chain_runner.py"
1168
- child_code = 'import sys; sys.stdout.buffer.write("report ✅".encode("utf-8"))'
1169
- legacy_console_encoding = "cp1252"
1170
- child_environment = dict(os.environ)
1171
- child_environment["USERPROFILE"] = str(tmp_home)
1172
- child_environment["HOME"] = str(tmp_home)
1173
- child_environment["PYTHONIOENCODING"] = legacy_console_encoding
1174
- child_environment.pop("PYTHONUTF8", None)
1175
- runner_command = [
1176
- sys.executable,
1177
- str(runner_script),
1178
- CLI_ARGUMENTS_SEPARATOR,
1179
- "-c",
1180
- child_code,
1181
- ]
1182
- completed = subprocess.run(
1183
- runner_command,
1184
- capture_output=True,
1185
- env=child_environment,
1186
- timeout=60,
1187
- )
1188
- assert completed.returncode == 0
1189
- assert b"Traceback" not in completed.stderr
1190
- assert "report ✅" in completed.stdout.decode(UTF8_ENCODING)
1191
-
1192
-
1193
- def test_usage_limit_classifier_keys_on_exact_signature_constants() -> None:
1194
- assert ALL_USAGE_LIMIT_SIGNATURES == (
1195
- "hit your session limit",
1196
- "usage limit reached",
1197
- "out of usage",
1198
- "usage quota exceeded",
1199
- )
1200
- assert _A_SIGNATURE in ALL_USAGE_LIMIT_SIGNATURES
1201
- assert runner._is_usage_limit_failure(
1202
- _completed("primary-launcher", 1, stderr=_A_SIGNATURE)
1203
- )
1204
- assert not runner._is_usage_limit_failure(
1205
- _completed("primary-launcher", 1, stderr=_GENERIC_PROCESS_ERROR_STDERR)
1206
- )
1207
-
1208
-
1209
- def test_extract_session_id_from_stdout_reads_json_object_and_ndjson() -> None:
1210
- single_object = (
1211
- f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
1212
- f'"result":"ENDORSE"}}'
1213
- )
1214
- assert runner.extract_session_id_from_stdout(single_object) == _BOUND_SESSION_ID
1215
- ndjson_stream = (
1216
- f'{{"type":"system","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}"}}\n'
1217
- f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
1218
- f'"result":"ok"}}\n'
1219
- )
1220
- assert runner.extract_session_id_from_stdout(ndjson_stream) == _BOUND_SESSION_ID
1221
- assert runner.extract_session_id_from_stdout("not json at all") is None
1222
- assert runner.extract_session_id_from_stdout("") is None
1223
-
1224
-
1225
- def test_ordered_account_mode_tries_primary_launcher_first(
1226
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1227
- ) -> None:
1228
- config_file = _write_chain_config(
1229
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1230
- )
1231
- usage_reporter = _usage_reporter_from_remaining(
1232
- {
1233
- _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1234
- _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1235
- }
1236
- )
1237
- recorder = _install(
1238
- monkeypatch,
1239
- config_file,
1240
- {
1241
- _PRIMARY_LAUNCHER: _completed(
1242
- _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1243
- ),
1244
- _SECONDARY_LAUNCHER: _completed(
1245
- _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1246
- ),
1247
- },
1248
- weekly_usage_reporter=usage_reporter,
1249
- )
1250
- chain_result = runner.run_claude(
1251
- _PROMPT_ARGUMENTS,
1252
- timeout_seconds=5,
1253
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1254
- )
1255
- assert chain_result.served_command == _PRIMARY_LAUNCHER
1256
- assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1257
- assert recorder.invocations[0][0] == _PRIMARY_LAUNCHER
1258
- assert usage_reporter.call_count["count"] == 0
1259
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
1260
- _PRIMARY_LAUNCHER
1261
- ]
1262
-
1263
-
1264
- def test_ordered_account_mode_usage_limit_falls_over_to_secondary(
1265
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1266
- ) -> None:
1267
- config_file = _write_chain_config(
1268
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1269
- )
1270
- _install(
1271
- monkeypatch,
1272
- config_file,
1273
- {
1274
- _PRIMARY_LAUNCHER: _completed(
1275
- _PRIMARY_LAUNCHER, 1, stderr=_A_SIGNATURE
1276
- ),
1277
- _SECONDARY_LAUNCHER: _completed(
1278
- _SECONDARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1279
- ),
1280
- },
1281
- )
1282
- chain_result = runner.run_claude(
1283
- _PROMPT_ARGUMENTS,
1284
- timeout_seconds=5,
1285
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1286
- )
1287
- assert chain_result.served_command == _SECONDARY_LAUNCHER
1288
- assert chain_result.returncode == 0
1289
- assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1290
- assert [each_attempt.command for each_attempt in chain_result.attempts] == [
1291
- _PRIMARY_LAUNCHER,
1292
- _SECONDARY_LAUNCHER,
1293
- ]
1294
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
1295
- assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
1296
-
1297
-
1298
- def test_ordered_account_mode_authentication_error_is_advisor_blocked(
1299
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1300
- ) -> None:
1301
- config_file = _write_chain_config(
1302
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1303
- )
1304
- recorder = _install(
1305
- monkeypatch,
1306
- config_file,
1307
- {
1308
- _PRIMARY_LAUNCHER: _completed(
1309
- _PRIMARY_LAUNCHER, 1, stderr=_AUTHENTICATION_ERROR_STDERR
1310
- ),
1311
- _SECONDARY_LAUNCHER: _completed(
1312
- _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1313
- ),
1314
- },
1315
- )
1316
- chain_result = runner.run_claude(
1317
- _PROMPT_ARGUMENTS,
1318
- timeout_seconds=5,
1319
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1320
- )
1321
- assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1322
- assert chain_result.served_command is None
1323
- assert chain_result.returncode == 1
1324
- assert _AUTHENTICATION_ERROR_STDERR in chain_result.stderr
1325
- assert len(recorder.invocations) == 1
1326
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
1327
-
1328
-
1329
- def test_ordered_account_mode_timeout_is_advisor_blocked(
1330
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1331
- ) -> None:
1332
- config_file = _write_chain_config(
1333
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1334
- )
1335
- recorder = _install(
1336
- monkeypatch,
1337
- config_file,
1338
- {
1339
- _PRIMARY_LAUNCHER: subprocess.TimeoutExpired(
1340
- cmd=[_PRIMARY_LAUNCHER], timeout=5
1341
- ),
1342
- _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1343
- },
1344
- )
1345
- chain_result = runner.run_claude(
1346
- _PROMPT_ARGUMENTS,
1347
- timeout_seconds=5,
1348
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1349
- )
1350
- assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1351
- assert chain_result.served_command is None
1352
- assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
1353
- assert len(recorder.invocations) == 1
1354
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
1355
-
1356
-
1357
- def test_ordered_account_mode_generic_process_error_is_advisor_blocked(
1358
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1359
- ) -> None:
1360
- config_file = _write_chain_config(
1361
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1362
- )
1363
- recorder = _install(
1364
- monkeypatch,
1365
- config_file,
1366
- {
1367
- _PRIMARY_LAUNCHER: _completed(
1368
- _PRIMARY_LAUNCHER, 2, stderr=_GENERIC_PROCESS_ERROR_STDERR
1369
- ),
1370
- _SECONDARY_LAUNCHER: _completed(
1371
- _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1372
- ),
1373
- },
1374
- )
1375
- chain_result = runner.run_claude(
1376
- _PROMPT_ARGUMENTS,
1377
- timeout_seconds=5,
1378
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1379
- )
1380
- assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1381
- assert chain_result.served_command is None
1382
- assert chain_result.returncode == 2
1383
- assert len(recorder.invocations) == 1
1384
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
1385
-
1386
-
1387
- def test_ordered_account_mode_configuration_error_does_not_fall_over(
1388
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
1389
- ) -> None:
1390
- missing_config = tmp_path / CONFIG_FILENAME
1391
- monkeypatch.setattr(runner, "chain_config_path", lambda: missing_config)
1392
- _install_tty_stdin(monkeypatch)
1393
- exit_code = runner.main(
1394
- [
1395
- CLI_ROUTING_MODE_FLAG,
1396
- ROUTING_MODE_ORDERED_ACCOUNT,
1397
- CLI_ARGUMENTS_SEPARATOR,
1398
- "-p",
1399
- "hi",
1400
- ]
1401
- )
1402
- captured = capsys.readouterr()
1403
- assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
1404
- assert EXAMPLE_CONFIG_FILENAME in captured.err
1405
-
1406
-
1407
- def test_ordered_account_mode_preserves_session_id_on_success(
1408
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1409
- ) -> None:
1410
- config_file = _write_chain_config(
1411
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1412
- )
1413
- _install(
1414
- monkeypatch,
1415
- config_file,
1416
- {
1417
- _PRIMARY_LAUNCHER: _completed(
1418
- _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1419
- ),
1420
- _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1421
- },
1422
- )
1423
- chain_result = runner.run_claude(
1424
- _PROMPT_ARGUMENTS,
1425
- timeout_seconds=5,
1426
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1427
- )
1428
- assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1429
- assert chain_result.session_id == _BOUND_SESSION_ID
1430
- assert chain_result.served_command == _PRIMARY_LAUNCHER
1431
-
1432
-
1433
- def test_default_routing_mode_remains_usage_ranked(
1434
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1435
- ) -> None:
1436
- config_file = _write_chain_config(
1437
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1438
- )
1439
- usage_reporter = _usage_reporter_from_remaining(
1440
- {
1441
- _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1442
- _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1443
- }
1444
- )
1445
- recorder = _install(
1446
- monkeypatch,
1447
- config_file,
1448
- {
1449
- _PRIMARY_LAUNCHER: _completed(
1450
- _PRIMARY_LAUNCHER, 0, stdout="from-primary"
1451
- ),
1452
- _SECONDARY_LAUNCHER: _completed(
1453
- _SECONDARY_LAUNCHER, 0, stdout="from-secondary"
1454
- ),
1455
- },
1456
- weekly_usage_reporter=usage_reporter,
1457
- )
1458
- chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
1459
- assert chain_result.served_command == _SECONDARY_LAUNCHER
1460
- assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1461
- assert recorder.invocations[0][0] == _SECONDARY_LAUNCHER
1462
- assert usage_reporter.call_count["count"] == 1
1463
-
1464
-
1465
- def test_cli_ordered_account_routing_mode_flag(
1466
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1467
- ) -> None:
1468
- config_file = _write_chain_config(
1469
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1470
- )
1471
- usage_reporter = _usage_reporter_from_remaining(
1472
- {
1473
- _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1474
- _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1475
- }
1476
- )
1477
- recorder = _install(
1478
- monkeypatch,
1479
- config_file,
1480
- {
1481
- _PRIMARY_LAUNCHER: _completed(
1482
- _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1483
- ),
1484
- _SECONDARY_LAUNCHER: _completed(
1485
- _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1486
- ),
1487
- },
1488
- weekly_usage_reporter=usage_reporter,
1489
- )
1490
- exit_code = runner.main(
1491
- [
1492
- CLI_ROUTING_MODE_FLAG,
1493
- ROUTING_MODE_ORDERED_ACCOUNT,
1494
- CLI_ARGUMENTS_SEPARATOR,
1495
- "-p",
1496
- "hi",
1497
- ]
1498
- )
1499
- assert exit_code == 0
1500
- assert recorder.invocations[0][0] == _PRIMARY_LAUNCHER
1501
- assert usage_reporter.call_count["count"] == 0
1502
-
1503
-
1504
- def test_cli_ordered_account_advisor_blocked_exit_code(
1505
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1506
- ) -> None:
1507
- config_file = _write_chain_config(
1508
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1509
- )
1510
- _install(
1511
- monkeypatch,
1512
- config_file,
1513
- {
1514
- _PRIMARY_LAUNCHER: _completed(
1515
- _PRIMARY_LAUNCHER, 2, stderr=_GENERIC_PROCESS_ERROR_STDERR
1516
- ),
1517
- _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1518
- },
1519
- )
1520
- exit_code = runner.main(
1521
- [
1522
- CLI_ROUTING_MODE_FLAG,
1523
- ROUTING_MODE_ORDERED_ACCOUNT,
1524
- CLI_ARGUMENTS_SEPARATOR,
1525
- "-p",
1526
- "hi",
1527
- ]
1528
- )
1529
- assert exit_code == CHAIN_ADVISOR_BLOCKED_EXIT_CODE
1530
-
1531
-
1532
- def test_default_affinity_state_path_joins_filename() -> None:
1533
- claude_home = Path("/tmp/claude-home")
1534
- resolved = default_affinity_state_path(claude_home)
1535
- assert resolved == claude_home / AFFINITY_STATE_FILENAME
1536
-
1537
-
1538
- def test_affinity_store_round_trip_is_versioned_and_atomic(tmp_path: Path) -> None:
1539
- state_path = tmp_path / AFFINITY_STATE_FILENAME
1540
- empty_store = load_affinity_store(state_path)
1541
- assert empty_store.schema_version == AFFINITY_STATE_SCHEMA_VERSION
1542
- assert empty_store.all_bindings == []
1543
-
1544
- updated = record_affinity_binding(
1545
- empty_store,
1546
- session_id=_BOUND_SESSION_ID,
1547
- command=_PRIMARY_LAUNCHER,
1548
- )
1549
- save_affinity_store_atomic(state_path, updated)
1550
-
1551
- reloaded = load_affinity_store(state_path)
1552
- assert reloaded.schema_version == AFFINITY_STATE_SCHEMA_VERSION
1553
- assert len(reloaded.all_bindings) == 1
1554
- assert reloaded.all_bindings[0].session_id == _BOUND_SESSION_ID
1555
- assert reloaded.all_bindings[0].command == _PRIMARY_LAUNCHER
1556
- on_disk = json.loads(state_path.read_text(encoding=UTF8_ENCODING))
1557
- assert on_disk[AFFINITY_KEY_SCHEMA_VERSION] == AFFINITY_STATE_SCHEMA_VERSION
1558
- assert on_disk[AFFINITY_KEY_ALL_BINDINGS][0][AFFINITY_KEY_SESSION_ID] == (
1559
- _BOUND_SESSION_ID
1560
- )
1561
- assert on_disk[AFFINITY_KEY_ALL_BINDINGS][0][AFFINITY_KEY_COMMAND] == (
1562
- _PRIMARY_LAUNCHER
1563
- )
1564
-
1565
-
1566
- def test_affinity_store_is_bounded_and_drops_oldest() -> None:
1567
- store = runner.AffinityStore()
1568
- for each_index in range(AFFINITY_MAXIMUM_ENTRIES + 3):
1569
- store = record_affinity_binding(
1570
- store,
1571
- session_id=f"session-{each_index}",
1572
- command=_PRIMARY_LAUNCHER,
1573
- maximum_entries=AFFINITY_MAXIMUM_ENTRIES,
1574
- )
1575
- assert len(store.all_bindings) == AFFINITY_MAXIMUM_ENTRIES
1576
- all_session_ids = [each.session_id for each in store.all_bindings]
1577
- assert "session-0" not in all_session_ids
1578
- assert "session-1" not in all_session_ids
1579
- assert "session-2" not in all_session_ids
1580
- assert f"session-{AFFINITY_MAXIMUM_ENTRIES + 2}" in all_session_ids
1581
-
1582
-
1583
- def test_affinity_rebind_moves_session_to_newest_end() -> None:
1584
- store = runner.AffinityStore()
1585
- store = record_affinity_binding(
1586
- store, session_id="a", command=_PRIMARY_LAUNCHER
1587
- )
1588
- store = record_affinity_binding(
1589
- store, session_id="b", command=_SECONDARY_LAUNCHER
1590
- )
1591
- store = record_affinity_binding(
1592
- store, session_id="a", command=_SECONDARY_LAUNCHER
1593
- )
1594
- assert [each.session_id for each in store.all_bindings] == ["b", "a"]
1595
- assert store.all_bindings[-1].command == _SECONDARY_LAUNCHER
1596
-
1597
-
1598
- def test_affinity_corrupt_state_raises_actionable_diagnostic(tmp_path: Path) -> None:
1599
- state_path = tmp_path / AFFINITY_STATE_FILENAME
1600
- state_path.write_text("{not-json", encoding=UTF8_ENCODING)
1601
- with pytest.raises(ValueError, match="corrupt or unreadable") as raised:
1602
- load_affinity_store(state_path)
1603
- assert str(state_path) in str(raised.value)
1604
-
1605
-
1606
- def test_affinity_unsupported_schema_version_raises_actionable_diagnostic(
1607
- tmp_path: Path,
1608
- ) -> None:
1609
- state_path = tmp_path / AFFINITY_STATE_FILENAME
1610
- state_path.write_text(
1611
- json.dumps(
1612
- {
1613
- AFFINITY_KEY_SCHEMA_VERSION: AFFINITY_STATE_SCHEMA_VERSION + 1,
1614
- AFFINITY_KEY_ALL_BINDINGS: [],
1615
- }
1616
- ),
1617
- encoding=UTF8_ENCODING,
1618
- )
1619
- with pytest.raises(ValueError, match="unsupported schema_version") as raised:
1620
- load_affinity_store(state_path)
1621
- assert str(state_path) in str(raised.value)
1622
-
1623
-
1624
- def test_affinity_binding_not_object_raises_actionable_diagnostic(
1625
- tmp_path: Path,
1626
- ) -> None:
1627
- state_path = tmp_path / AFFINITY_STATE_FILENAME
1628
- state_path.write_text(
1629
- json.dumps(
1630
- {
1631
- AFFINITY_KEY_SCHEMA_VERSION: AFFINITY_STATE_SCHEMA_VERSION,
1632
- AFFINITY_KEY_ALL_BINDINGS: ["not-an-object"],
1633
- }
1634
- ),
1635
- encoding=UTF8_ENCODING,
1636
- )
1637
- with pytest.raises(ValueError, match="not an object") as raised:
1638
- load_affinity_store(state_path)
1639
- assert str(state_path) in str(raised.value)
1640
-
1641
-
1642
- def test_record_affinity_binding_rejects_empty_session_or_command() -> None:
1643
- store = runner.AffinityStore()
1644
- with pytest.raises(ValueError, match="session_id and command"):
1645
- record_affinity_binding(store, session_id="", command=_PRIMARY_LAUNCHER)
1646
- with pytest.raises(ValueError, match="session_id and command"):
1647
- record_affinity_binding(store, session_id=_BOUND_SESSION_ID, command="")
1648
-
1649
-
1650
- def test_record_affinity_binding_rejects_non_positive_maximum_entries() -> None:
1651
- store = runner.AffinityStore()
1652
- with pytest.raises(ValueError, match="maximum_entries must be at least 1"):
1653
- record_affinity_binding(
1654
- store,
1655
- session_id=_BOUND_SESSION_ID,
1656
- command=_PRIMARY_LAUNCHER,
1657
- maximum_entries=0,
1658
- )
1659
-
1660
-
1661
- def test_affinity_write_failure_raises_actionable_diagnostic(
1662
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1663
- ) -> None:
1664
- state_path = tmp_path / AFFINITY_STATE_FILENAME
1665
- store = record_affinity_binding(
1666
- runner.AffinityStore(),
1667
- session_id=_BOUND_SESSION_ID,
1668
- command=_PRIMARY_LAUNCHER,
1669
- )
1670
-
1671
- def _raise_replace(_source: object, _destination: object) -> None:
1672
- raise OSError("disk full")
1673
-
1674
- monkeypatch.setattr(runner.os, "replace", _raise_replace)
1675
- with pytest.raises(OSError, match="Failed to write affinity state") as raised:
1676
- save_affinity_store_atomic(state_path, store)
1677
- assert str(state_path) in str(raised.value)
1678
-
1679
-
1680
- def test_extract_resume_session_id_reads_flag() -> None:
1681
- assert extract_resume_session_id(
1682
- [RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"]
1683
- ) == _BOUND_SESSION_ID
1684
- assert extract_resume_session_id(["-p", "hi"]) is None
1685
- assert extract_resume_session_id([RESUME_SESSION_FLAG]) is None
1686
-
1687
-
1688
- def test_order_entries_for_resume_puts_originating_binary_first() -> None:
1689
- all_entries = [
1690
- ChainEntry(command=_PRIMARY_LAUNCHER, extra_args=()),
1691
- ChainEntry(command=_SECONDARY_LAUNCHER, extra_args=()),
1692
- ]
1693
- ordered = order_entries_for_resume(
1694
- all_entries, preferred_command=_SECONDARY_LAUNCHER
1695
- )
1696
- assert [each.command for each in ordered] == [
1697
- _SECONDARY_LAUNCHER,
1698
- _PRIMARY_LAUNCHER,
1699
- ]
1700
-
1701
-
1702
- def test_order_entries_for_resume_falls_back_when_command_missing() -> None:
1703
- all_entries = [
1704
- ChainEntry(command=_PRIMARY_LAUNCHER, extra_args=()),
1705
- ChainEntry(command=_SECONDARY_LAUNCHER, extra_args=()),
1706
- ]
1707
- ordered = order_entries_for_resume(
1708
- all_entries, preferred_command="missing-binary"
1709
- )
1710
- assert [each.command for each in ordered] == [
1711
- _PRIMARY_LAUNCHER,
1712
- _SECONDARY_LAUNCHER,
1713
- ]
1714
- ordered_none = order_entries_for_resume(all_entries, preferred_command=None)
1715
- assert [each.command for each in ordered_none] == [
1716
- _PRIMARY_LAUNCHER,
1717
- _SECONDARY_LAUNCHER,
1718
- ]
1719
-
1720
-
1721
- def test_lookup_affinity_command_returns_bound_binary() -> None:
1722
- store = record_affinity_binding(
1723
- runner.AffinityStore(),
1724
- session_id=_BOUND_SESSION_ID,
1725
- command=_SECONDARY_LAUNCHER,
1726
- )
1727
- assert lookup_affinity_command(store, _BOUND_SESSION_ID) == _SECONDARY_LAUNCHER
1728
- assert lookup_affinity_command(store, "unknown") is None
1729
-
1730
-
1731
- def test_run_claude_resume_routes_through_originating_binary(
1732
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1733
- ) -> None:
1734
- config_file = _write_chain_config(
1735
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1736
- )
1737
- affinity_path = tmp_path / "claude-chain-affinity.json"
1738
- store = record_affinity_binding(
1739
- runner.AffinityStore(),
1740
- session_id=_BOUND_SESSION_ID,
1741
- command=_SECONDARY_LAUNCHER,
1742
- )
1743
- save_affinity_store_atomic(affinity_path, store)
1744
-
1745
- monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
1746
- monkeypatch.setattr(
1747
- runner,
1748
- "default_affinity_state_path",
1749
- lambda _home: affinity_path,
1750
- )
1751
- monkeypatch.setattr(
1752
- runner,
1753
- "chain_weekly_usage_reporter",
1754
- _usage_reporter_from_remaining(
1755
- {
1756
- _PRIMARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1757
- _SECONDARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1758
- }
1759
- ),
1760
- )
1761
- all_called: list[str] = []
1762
-
1763
- def _runner(command, **_kwargs):
1764
- all_called.append(command[0])
1765
- return _completed(command[0], 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION)
1766
-
1767
- monkeypatch.setattr(runner, "chain_subprocess_runner", _runner)
1768
- outcome = runner.run_claude(
1769
- [RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"],
1770
- timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
1771
- )
1772
- assert outcome.served_command == _SECONDARY_LAUNCHER
1773
- assert all_called[0] == _SECONDARY_LAUNCHER
1774
-
1775
-
1776
- def test_run_claude_success_persists_session_affinity(
1777
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1778
- ) -> None:
1779
- config_file = _write_chain_config(
1780
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1781
- )
1782
- _install(
1783
- monkeypatch,
1784
- config_file,
1785
- {
1786
- _PRIMARY_LAUNCHER: _completed(
1787
- _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1788
- ),
1789
- _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1790
- },
1791
- )
1792
- chain_result = runner.run_claude(
1793
- _PROMPT_ARGUMENTS,
1794
- timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
1795
- routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1796
- )
1797
- assert chain_result.served_command == _PRIMARY_LAUNCHER
1798
- assert chain_result.session_id == _BOUND_SESSION_ID
1799
- affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
1800
- reloaded = load_affinity_store(affinity_path)
1801
- assert len(reloaded.all_bindings) == 1
1802
- assert reloaded.all_bindings[0].session_id == _BOUND_SESSION_ID
1803
- assert reloaded.all_bindings[0].command == _PRIMARY_LAUNCHER
1804
-
1805
-
1806
- def test_run_claude_resume_falls_back_when_affinity_corrupt(
1807
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1808
- ) -> None:
1809
- config_file = _write_chain_config(
1810
- tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1811
- )
1812
- affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
1813
- affinity_path.write_text("{not-json", encoding=UTF8_ENCODING)
1814
- usage_reporter = _usage_reporter_from_remaining(
1815
- {
1816
- _PRIMARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1817
- _SECONDARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1818
- }
1819
- )
1820
- all_called: list[str] = []
1821
-
1822
- def _runner(command, **_kwargs):
1823
- all_called.append(command[0])
1824
- return _completed(command[0], 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION)
1825
-
1826
- monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
1827
- monkeypatch.setattr(
1828
- runner,
1829
- "default_affinity_state_path",
1830
- lambda _home, _affinity_path=affinity_path: _affinity_path,
1831
- )
1832
- monkeypatch.setattr(runner, "chain_weekly_usage_reporter", usage_reporter)
1833
- monkeypatch.setattr(runner, "chain_subprocess_runner", _runner)
1834
- outcome = runner.run_claude(
1835
- [RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"],
1836
- timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
1837
- )
1838
- assert outcome.served_command == _PRIMARY_LAUNCHER
1839
- assert all_called[0] == _PRIMARY_LAUNCHER