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.
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/cli-chain.md +9 -12
- package/_shared/advisor/reference/lifecycle.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +3 -3
- package/bin/install.test.mjs +1 -1
- package/docs/second-claude-account.md +11 -13
- package/package.json +1 -1
- package/scripts/_code_review_test_support.py +29 -39
- package/scripts/claude_account_worker.py +46 -297
- package/scripts/claude_account_worker_process.py +18 -154
- package/scripts/claude_account_worker_report.py +8 -4
- package/scripts/claude_chain_usage.py +7 -271
- package/scripts/codec_forwarding_test_support.py +11 -65
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
- package/scripts/invoke_code_review.py +34 -56
- package/scripts/resolve_worker_spawn.py +40 -72
- package/scripts/test_account_broker_guard.py +0 -4
- package/scripts/test_claude_account_worker.py +105 -350
- package/scripts/test_claude_chain_usage.py +50 -510
- package/scripts/test_dispatcher_profile_import.py +5 -9
- package/scripts/test_invoke_code_review.py +7 -6
- package/scripts/test_invoke_code_review_chain.py +26 -0
- package/scripts/test_invoke_code_review_cli.py +8 -8
- package/scripts/test_invoke_code_review_codec.py +3 -11
- package/scripts/test_invoke_code_review_contract.py +5 -1
- package/scripts/test_resolve_worker_spawn.py +118 -265
- package/scripts/test_resolve_worker_spawn_codec.py +9 -21
- package/scripts/tests/test_code_review_constants.py +2 -2
- package/scripts/claude_account_choice.py +0 -422
- package/scripts/claude_chain_runner.py +0 -1289
- package/scripts/test_claude_account_choice.py +0 -355
- 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
|