claude-multiacc 2.0.53 → 2.0.55
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/README.md +5 -3
- package/bin/codex +26 -5
- package/docs/AUTORESUME.md +21 -6
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +22 -5
- package/lib/autoresume_receipt.py +78 -0
- package/lib/claude_pool_prepare.py +2 -0
- package/lib/codex_autoresume_mode.py +58 -0
- package/package.json +1 -1
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/autoresume_tmux_support.py +135 -0
- package/tests/fake_codex_tui.py +81 -0
- package/tests/run-tests.sh +10 -1
- package/tests/test_autoresume.py +7 -0
- package/tests/test_autoresume_codex_records.py +74 -0
- package/tests/test_autoresume_cursor.py +87 -0
- package/tests/test_autoresume_mode.py +89 -0
- package/tests/test_autoresume_receipt.py +117 -0
- package/tests/test_autoresume_tmux.py +100 -0
- package/tests/test_autoresume_tmux_modes.py +80 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Real, isolated tmux fixtures; no provider CLI, credentials, network or schedulers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
SID = "01a0cb0f-a719-7461-bba6-a59bdf2412cc"
|
|
16
|
+
# The shell suite supplies fake tmux on PATH for its own tests; these need the real server.
|
|
17
|
+
ORIGINAL_PATH = os.environ.get("MULTIACC_TEST_ORIGINAL_PATH", os.environ["PATH"])
|
|
18
|
+
TMUX = shutil.which("tmux", path=ORIGINAL_PATH)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def seed_pool(home):
|
|
22
|
+
pool = home / "pool"
|
|
23
|
+
pool.mkdir()
|
|
24
|
+
sessions = home / ".codex" / "sessions"
|
|
25
|
+
sessions.mkdir(parents=True)
|
|
26
|
+
(pool / "accounts.json").write_text('{"accounts":[],"threshold":90}')
|
|
27
|
+
(pool / ".limits-kick").touch()
|
|
28
|
+
for name, percent in (("acct-01", 10), ("acct-02", 30)):
|
|
29
|
+
account = pool / name
|
|
30
|
+
account.mkdir()
|
|
31
|
+
(account / "auth.json").write_text('{"tokens":{"access_token":"test-only"}}')
|
|
32
|
+
(account / "sessions").symlink_to(sessions, target_is_directory=True)
|
|
33
|
+
(account / "limits.json").write_text(json.dumps({"fetched_at": int(time.time()),
|
|
34
|
+
"max_percent": percent, "weekly_percent": percent, "session_percent": 10}, indent=1))
|
|
35
|
+
return pool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_environment(home, pool, overrides):
|
|
39
|
+
fake = home / "fake"
|
|
40
|
+
fake.mkdir()
|
|
41
|
+
binary = fake / "codex"
|
|
42
|
+
binary.write_text("#!" + sys.executable + "\n" +
|
|
43
|
+
(ROOT / "tests/fake_codex_tui.py").read_text())
|
|
44
|
+
binary.chmod(0o755)
|
|
45
|
+
env = {"HOME": str(home), "PATH": str(fake) + os.pathsep + ORIGINAL_PATH,
|
|
46
|
+
"TERM": "xterm-256color", "SHELL": "/bin/bash", "LC_ALL": "C", "FAKE_HOME": str(home),
|
|
47
|
+
"CODEX_ACCOUNTS_ROOT": str(pool), "CODEX_MULTIACC_PYTHON": sys.executable,
|
|
48
|
+
"CODEX_MULTIACC_AUTORESUME": "1", "CODEX_MULTIACC_AUTORESUME_DEBUG": "1",
|
|
49
|
+
"CODEX_MULTIACC_MCP": "0", "CODEX_MULTIACC_AUTO_RESET": "0", "CODEX_MULTIACC_NO_SYNC": "1",
|
|
50
|
+
"CODEX_MULTIACC_CLIENT_LIMITS": "0", "CODEX_MULTIACC_HEADROOM_BAND": "0",
|
|
51
|
+
"CODEX_MULTIACC_USAGE_URL": "file:///nonexistent", "CODEX_MULTIACC_TOKEN_URL": "file:///nonexistent",
|
|
52
|
+
"CODEX_MULTIACC_AR_POLL": "0.05", "CODEX_MULTIACC_AR_GRACE": "0.4",
|
|
53
|
+
"CODEX_MULTIACC_AR_HOLD_REPROBE": "0.3", "CODEX_MULTIACC_AR_TERM_GRACE": "0.3",
|
|
54
|
+
"CODEX_MULTIACC_AR_PANE_WAIT": "4", "CODEX_MULTIACC_AR_RELAUNCH_WAIT": "5",
|
|
55
|
+
"CODEX_MULTIACC_AR_PROMPT_WAIT": "2",
|
|
56
|
+
"CODEX_MULTIACC_AR_TRANSIENT_LADDER": "0.4", "FAKE_SID": SID}
|
|
57
|
+
env.update(overrides)
|
|
58
|
+
return env
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TmuxScenario:
|
|
62
|
+
def __init__(self, **overrides):
|
|
63
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ar-tmux-", dir="/tmp")
|
|
64
|
+
self.home = Path(self.temp.name).resolve()
|
|
65
|
+
self.pool = seed_pool(self.home)
|
|
66
|
+
self.work = self.home / "work"
|
|
67
|
+
self.work.mkdir()
|
|
68
|
+
self.sock = self.home / "tmux.sock"
|
|
69
|
+
self.env = test_environment(self.home, self.pool, overrides)
|
|
70
|
+
self.pane = "%0"
|
|
71
|
+
self.log = self.pool / "selection.log"
|
|
72
|
+
self.rollout = self.home / ".codex/sessions/2026/10/10" / ("rollout-test-" + SID + ".jsonl")
|
|
73
|
+
|
|
74
|
+
def tmux(self, *args, check=True):
|
|
75
|
+
result = subprocess.run([TMUX, "-S", str(self.sock), "-f", "/dev/null", *args],
|
|
76
|
+
env=self.env, capture_output=True, text=True, timeout=10)
|
|
77
|
+
if check and result.returncode:
|
|
78
|
+
raise AssertionError(result.stderr)
|
|
79
|
+
return result.stdout.strip()
|
|
80
|
+
|
|
81
|
+
def start(self, args=None, supervised=True):
|
|
82
|
+
self.pane = self.tmux("new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "case",
|
|
83
|
+
"-c", str(self.work), "/bin/bash", "--noprofile", "--norc", "-i")
|
|
84
|
+
command = shlex.join([str(ROOT / "bin/codex"), *(args if args is not None else ["--yolo"])])
|
|
85
|
+
self.tmux("send-keys", "-t", self.pane, "-l", command)
|
|
86
|
+
self.tmux("send-keys", "-t", self.pane, "Enter")
|
|
87
|
+
self.wait(lambda: len(self.records("ready")) == 1, "first fake TUI ready")
|
|
88
|
+
if supervised:
|
|
89
|
+
self.wait(lambda: "autoresume watch" in self.log.read_text(), "watcher started")
|
|
90
|
+
|
|
91
|
+
def records(self, kind=None):
|
|
92
|
+
path = self.home / "provider.jsonl"
|
|
93
|
+
try:
|
|
94
|
+
rows = [json.loads(line) for line in path.read_text().splitlines()]
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
return []
|
|
97
|
+
return [row for row in rows if kind is None or row["kind"] == kind]
|
|
98
|
+
|
|
99
|
+
def append(self, payload, age=0):
|
|
100
|
+
timestamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - age)) + ".999Z"
|
|
101
|
+
with self.rollout.open("a") as target:
|
|
102
|
+
target.write(json.dumps({"timestamp": timestamp, "type": "event_msg", "payload": payload}) + "\n")
|
|
103
|
+
|
|
104
|
+
def wait(self, predicate, label, timeout=12):
|
|
105
|
+
deadline = time.monotonic() + timeout
|
|
106
|
+
while time.monotonic() < deadline:
|
|
107
|
+
if predicate():
|
|
108
|
+
return
|
|
109
|
+
time.sleep(0.05)
|
|
110
|
+
logs = self.log.read_text() if self.log.exists() else "no selection log"
|
|
111
|
+
logs += "\n" + self.tmux("capture-pane", "-p", "-t", self.pane, check=False)
|
|
112
|
+
for path in (self.pool / "tmp/autoresume").glob("*.log"):
|
|
113
|
+
logs += "\n" + path.read_text()
|
|
114
|
+
raise AssertionError(label + " timed out:\n" + logs)
|
|
115
|
+
|
|
116
|
+
def close(self):
|
|
117
|
+
if not self.home.exists():
|
|
118
|
+
return
|
|
119
|
+
(self.pool / "autoresume.off").touch()
|
|
120
|
+
deadline = time.monotonic() + 3
|
|
121
|
+
while list((self.pool / "tmp/autoresume").glob("*.state")) and time.monotonic() < deadline:
|
|
122
|
+
time.sleep(0.05)
|
|
123
|
+
if self.sock.exists():
|
|
124
|
+
self.tmux("kill-server", check=False)
|
|
125
|
+
processes = subprocess.run(["ps", "-A", "-o", "pid=,args="], capture_output=True,
|
|
126
|
+
text=True, env=self.env, timeout=5).stdout
|
|
127
|
+
owned = "watch --state " + str(self.pool / "tmp/autoresume") + "/"
|
|
128
|
+
for row in processes.splitlines():
|
|
129
|
+
parts = row.strip().split(None, 1)
|
|
130
|
+
if len(parts) == 2 and owned in parts[1]:
|
|
131
|
+
try:
|
|
132
|
+
os.kill(int(parts[0]), signal.SIGTERM)
|
|
133
|
+
except ProcessLookupError:
|
|
134
|
+
pass
|
|
135
|
+
self.temp.cleanup()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A fake foreground Codex that holds its rollout open and records every received key."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import termios
|
|
10
|
+
import time
|
|
11
|
+
import tty
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def record(kind, **fields):
|
|
15
|
+
with (Path(os.environ["FAKE_HOME"]) / "provider.jsonl").open("a") as target:
|
|
16
|
+
target.write(json.dumps(dict(kind=kind, pid=os.getpid(), **fields)) + "\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def stop(signum, _frame):
|
|
20
|
+
record("signal", signal=signum)
|
|
21
|
+
if os.environ.get("FAKE_IGNORE_TERM") == "1" and signum == signal.SIGTERM:
|
|
22
|
+
return
|
|
23
|
+
raise SystemExit(0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def initialize_rollout():
|
|
27
|
+
sid = os.environ["FAKE_SID"]
|
|
28
|
+
directory = Path(os.environ["CODEX_HOME"]) / "sessions/2026/10/10"
|
|
29
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
rollout = (directory / ("rollout-test-" + sid + ".jsonl")).open("a+")
|
|
31
|
+
if rollout.tell() == 0:
|
|
32
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
33
|
+
rollout.write(json.dumps({"timestamp": stamp, "type": "session_meta", "payload": {
|
|
34
|
+
"id": sid, "session_id": sid, "timestamp": stamp,
|
|
35
|
+
"cwd": os.getcwd(), "source": "cli"}}) + "\n")
|
|
36
|
+
local = "--no-daemon" in sys.argv or "FAKE_LEGACY" in os.environ
|
|
37
|
+
if "resume" in sys.argv and sid in sys.argv and local and "FAKE_DROP_PROMPT" not in os.environ:
|
|
38
|
+
prompt = sys.argv[-1]
|
|
39
|
+
if " " in prompt:
|
|
40
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
41
|
+
rollout.write(json.dumps({"timestamp": stamp, "type": "response_item", "payload": {
|
|
42
|
+
"type": "message", "role": "user",
|
|
43
|
+
"content": [{"type": "input_text", "text": prompt}]}}) + "\n")
|
|
44
|
+
rollout.write(json.dumps({"timestamp": stamp, "type": "event_msg",
|
|
45
|
+
"payload": {"type": "task_started"}}) + "\n")
|
|
46
|
+
record("prompt", text=prompt)
|
|
47
|
+
rollout.flush()
|
|
48
|
+
if not local:
|
|
49
|
+
rollout.close() # Modern daemon owns the writer; the foreground client has no rollout fd.
|
|
50
|
+
return rollout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main():
|
|
54
|
+
if sys.argv[1:] == ["--help"]:
|
|
55
|
+
print("Usage: codex [OPTIONS]\n" + (" --no-daemon Run locally" if "FAKE_LEGACY" not in os.environ else ""))
|
|
56
|
+
return
|
|
57
|
+
if sys.argv[1:] == ["--version"]:
|
|
58
|
+
print("codex-cli test")
|
|
59
|
+
return
|
|
60
|
+
record("launch", argv=sys.argv[1:], cwd=os.getcwd(), account=Path(os.environ["CODEX_HOME"]).name)
|
|
61
|
+
settings = termios.tcgetattr(0)
|
|
62
|
+
signal.signal(signal.SIGTERM, stop)
|
|
63
|
+
signal.signal(signal.SIGHUP, stop)
|
|
64
|
+
rollout = initialize_rollout()
|
|
65
|
+
try:
|
|
66
|
+
tty.setraw(0)
|
|
67
|
+
record("ready")
|
|
68
|
+
print("FAKE CODEX READY", flush=True)
|
|
69
|
+
while True:
|
|
70
|
+
if select.select([0], [], [], 0.1)[0]:
|
|
71
|
+
data = os.read(0, 4096)
|
|
72
|
+
if not data:
|
|
73
|
+
return
|
|
74
|
+
record("keys", data=data.hex())
|
|
75
|
+
finally:
|
|
76
|
+
rollout.close()
|
|
77
|
+
termios.tcsetattr(0, termios.TCSANOW, settings)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
package/tests/run-tests.sh
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# No network, no real accounts, no quota: fake `claude`/`codex` binaries + file://
|
|
4
4
|
# usage/token fixtures.
|
|
5
5
|
set -u
|
|
6
|
+
export MULTIACC_TEST_ORIGINAL_PATH="$PATH"
|
|
6
7
|
|
|
7
8
|
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
8
9
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")" || exit 1
|
|
@@ -5156,6 +5157,12 @@ exec 9>>"$ro"
|
|
|
5156
5157
|
trap 'exit 0' TERM
|
|
5157
5158
|
trap 'exit 0' HUP
|
|
5158
5159
|
if [ "$resumed" = 1 ]; then
|
|
5160
|
+
python3 - "$ro" "${!#}" <<'PY'
|
|
5161
|
+
import json, sys
|
|
5162
|
+
with open(sys.argv[1], 'a') as stream:
|
|
5163
|
+
stream.write(json.dumps({'type': 'event_msg', 'payload': {
|
|
5164
|
+
'type': 'user_message', 'message': sys.argv[2]}}) + '\n')
|
|
5165
|
+
PY
|
|
5159
5166
|
[ -n "${FAKE_TUI_QUIET:-}" ] \
|
|
5160
5167
|
|| printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_started","turn_id":"2"}}\n' "$(ts)" >&9
|
|
5161
5168
|
else
|
|
@@ -5179,6 +5186,7 @@ EOF
|
|
|
5179
5186
|
cat > "$FAKEBIN/codex" <<EOF
|
|
5180
5187
|
#!/usr/bin/env bash
|
|
5181
5188
|
# fake real codex for tests (not a shim)
|
|
5189
|
+
if [ "\${1:-}" = --help ]; then echo 'Usage: legacy codex [OPTIONS]'; exit 0; fi
|
|
5182
5190
|
if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
|
|
5183
5191
|
python3 "$FAKE_CODEX_MCP" "\$CODEX_HOME/config.toml" "\$@"
|
|
5184
5192
|
rc=\$?
|
|
@@ -9237,7 +9245,8 @@ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
|
|
|
9237
9245
|
&& t_ok "AR codex quota: the old account gets the 10-minute error-cooldown, never client:7d" \
|
|
9238
9246
|
|| t_fail "AR codex quota: cooldown marker" "$m1 / $m2"
|
|
9239
9247
|
ar_logged "$ARX" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=quota depth=1\$" \
|
|
9240
|
-
&& ar_logged "$ARX"
|
|
9248
|
+
&& ar_wait 5 ar_logged "$ARX" \
|
|
9249
|
+
"autoresume switch provider=codex chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_CXSID depth=1 probe=$to" \
|
|
9241
9250
|
&& t_ok "AR codex quota: switch + relaunch logged" \
|
|
9242
9251
|
|| t_fail "AR codex quota: log lines" "$(grep autoresume "$ARX/selection.log")"
|
|
9243
9252
|
ar_typed_ok CODEX "$AR_CXSID" "CODEX_ACCOUNTS_ROOT=$(cd "$ARX" && pwd)" "$REPO_DIR/bin/codex" \
|
package/tests/test_autoresume.py
CHANGED
|
@@ -1033,6 +1033,13 @@ class FakeRunner:
|
|
|
1033
1033
|
except OSError:
|
|
1034
1034
|
return
|
|
1035
1035
|
self.consumed[tok] = (text, data)
|
|
1036
|
+
fields = ar.parse_kv(text, ar.RELAUNCH_KEYS)
|
|
1037
|
+
if fields.get("provider") == "codex":
|
|
1038
|
+
for path in Path(self.ar_dir).parent.parent.glob("acct-*/sessions/*/*/*/rollout-*.jsonl"):
|
|
1039
|
+
if fields["sid"] in path.name:
|
|
1040
|
+
append(path, [cx_event(self.t, {"type": "user_message",
|
|
1041
|
+
"message": ar.decode_argv(data)[-1]})])
|
|
1042
|
+
break
|
|
1036
1043
|
ar.remove_quietly(base + ".relaunch", base + ".argv")
|
|
1037
1044
|
|
|
1038
1045
|
def run(self, argv, timeout, env=None, cwd=None):
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Replay current Codex rollout shapes through the complete watcher, without a real pool."""
|
|
3
|
+
|
|
4
|
+
import signal
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import test_autoresume as fixtures
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CurrentCodexRecordsTests(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.pool = fixtures.CodexWatcherTests(methodName="runTest")
|
|
13
|
+
self.pool.setUp()
|
|
14
|
+
self.addCleanup(self.pool.tearDown)
|
|
15
|
+
|
|
16
|
+
def records(self):
|
|
17
|
+
pool = self.pool
|
|
18
|
+
reset = int(pool.t0) + 86400
|
|
19
|
+
meta = fixtures.cx_meta(pool.launched - 3600, cwd=str(pool.work))
|
|
20
|
+
meta["payload"]["cli_version"] = "0.162.1"
|
|
21
|
+
error = fixtures.cx_error(pool.t0 - 1, "usage_limit_exceeded")
|
|
22
|
+
error["payload"].update(root_turn_id=error["payload"]["turn_id"],
|
|
23
|
+
time_to_first_token_ms=31)
|
|
24
|
+
return [meta, fixtures.cx_tokens(pool.t0 - 2, primary={
|
|
25
|
+
"used_percent": 100.0, "window_minutes": 10080, "resets_at": reset}),
|
|
26
|
+
fixtures.cx_tokens(pool.t0 - 1, limit_id="premium"), error], reset
|
|
27
|
+
|
|
28
|
+
def test_current_epoch_error_and_weekly_snapshot_resume_same_session(self):
|
|
29
|
+
pool = self.pool
|
|
30
|
+
records, reset = self.records()
|
|
31
|
+
rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records)
|
|
32
|
+
self.assertEqual(rc, 0)
|
|
33
|
+
fields, argv = pool.relaunch_of(runner)
|
|
34
|
+
self.assertEqual(fields["sid"], fixtures.CX_SID)
|
|
35
|
+
self.assertEqual(fields["reset"], str(reset))
|
|
36
|
+
self.assertEqual(argv, ["resume", fixtures.CX_SID, fixtures.ar.resume_prompt("quota")])
|
|
37
|
+
self.assertEqual(watcher.tracker.pending["rtype_window"], "7d")
|
|
38
|
+
self.assertEqual(runner.signals, [(pool.PID, signal.SIGTERM)])
|
|
39
|
+
self.assertEqual(pool.events(), ["watch", "detect", "switch"])
|
|
40
|
+
|
|
41
|
+
def test_paginated_user_turn_cancels_before_restart(self):
|
|
42
|
+
pool = self.pool
|
|
43
|
+
records, _ = self.records()
|
|
44
|
+
|
|
45
|
+
def setup(watcher, runner, path):
|
|
46
|
+
started = fixtures.cx_event(pool.t0 + 1, {"type": "task_started"})
|
|
47
|
+
user = {"timestamp": fixtures.iso(pool.t0 + 1), "type": "response_item",
|
|
48
|
+
"payload": {"type": "message", "role": "user", "content": [
|
|
49
|
+
{"type": "input_text", "text": "Resume the current task"}]}}
|
|
50
|
+
runner.at(pool.t0 + 1, lambda: fixtures.append(path, [started, user]))
|
|
51
|
+
runner.at(pool.t0 + 10, lambda: runner.exit(pool.PID))
|
|
52
|
+
|
|
53
|
+
rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
|
|
54
|
+
self.assertEqual(rc, 0)
|
|
55
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
56
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
57
|
+
|
|
58
|
+
def test_old_epoch_error_in_resumed_paginated_rollout_is_ignored(self):
|
|
59
|
+
pool = self.pool
|
|
60
|
+
records, _ = self.records()
|
|
61
|
+
records[-1]["payload"]["completed_at"] = pool.launched - 3600
|
|
62
|
+
|
|
63
|
+
def setup(watcher, runner, path):
|
|
64
|
+
runner.at(pool.t0 + 10, lambda: runner.exit(pool.PID))
|
|
65
|
+
|
|
66
|
+
rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
|
|
67
|
+
self.assertEqual(rc, 0)
|
|
68
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
69
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
70
|
+
self.assertEqual(pool.events(), ["watch"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
unittest.main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Relaunched watchers ignore exactly the previous client's persisted rollout bytes."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import signal
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import test_autoresume as fixtures
|
|
8
|
+
from autoresume_receipt import apply_cursor, cursor_value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CursorTests(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.pool = fixtures.CodexWatcherTests(methodName="runTest")
|
|
14
|
+
self.pool.setUp()
|
|
15
|
+
self.addCleanup(self.pool.tearDown)
|
|
16
|
+
|
|
17
|
+
def resumed_watcher(self):
|
|
18
|
+
pool = self.pool
|
|
19
|
+
state = pool.write_state(["resume", fixtures.CX_SID], provider="codex", launched=int(pool.t0))
|
|
20
|
+
path = pool.rollout([fixtures.cx_meta(pool.t0 - 3600, cwd=str(pool.work)),
|
|
21
|
+
fixtures.cx_error(pool.t0, "usage_limit_exceeded")])
|
|
22
|
+
info = path.stat()
|
|
23
|
+
cursor = "%d:%d" % (info.st_ino, info.st_size)
|
|
24
|
+
state_path = Path(state)
|
|
25
|
+
state_path.write_text(state_path.read_text() + "rollout_cursor=" + cursor + "\n")
|
|
26
|
+
watcher, runner = pool.watcher(state, {"CODEX_MULTIACC_AR_GRACE": "0.1",
|
|
27
|
+
"CODEX_MULTIACC_AR_POLL": "0.05"})
|
|
28
|
+
runner.pane_cmd = "node"
|
|
29
|
+
return watcher, runner, path, cursor
|
|
30
|
+
|
|
31
|
+
def test_same_second_previous_quota_does_not_restart_new_client(self):
|
|
32
|
+
watcher, runner, _, cursor = self.resumed_watcher()
|
|
33
|
+
runner.at(self.pool.t0 + 1, lambda: runner.exit(self.pool.PID))
|
|
34
|
+
self.assertEqual(watcher.run(), 0)
|
|
35
|
+
self.assertEqual(watcher.st["rollout_cursor"], cursor)
|
|
36
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
37
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
38
|
+
self.assertEqual(self.pool.events(), ["watch"])
|
|
39
|
+
|
|
40
|
+
def test_new_quota_after_cursor_restarts_even_with_same_timestamp(self):
|
|
41
|
+
watcher, runner, path, _ = self.resumed_watcher()
|
|
42
|
+
runner.at(self.pool.t0 + 0.2, lambda: fixtures.append(path, [
|
|
43
|
+
fixtures.cx_error(self.pool.t0, "usage_limit_exceeded")]))
|
|
44
|
+
self.assertEqual(watcher.run(), 0)
|
|
45
|
+
self.assertEqual(self.pool.events(), ["watch", "detect", "switch"])
|
|
46
|
+
self.assertEqual(runner.signals, [(self.pool.PID, signal.SIGTERM)])
|
|
47
|
+
fields, argv = self.pool.relaunch_of(runner)
|
|
48
|
+
inode, offset = cursor_value(fields["rollout_cursor"])
|
|
49
|
+
self.assertEqual(inode, path.stat().st_ino)
|
|
50
|
+
self.assertLess(offset, path.stat().st_size)
|
|
51
|
+
self.assertEqual(argv[-2], fixtures.CX_SID)
|
|
52
|
+
|
|
53
|
+
def test_replaced_rollout_refuses_without_stopping_client(self):
|
|
54
|
+
watcher, runner, path, _ = self.resumed_watcher()
|
|
55
|
+
replacement = path.with_suffix(".replacement")
|
|
56
|
+
replacement.write_bytes(path.read_bytes())
|
|
57
|
+
replacement.replace(path)
|
|
58
|
+
self.assertEqual(watcher.run(), 0)
|
|
59
|
+
self.assertEqual(self.pool.events(), ["watch", "giveup"])
|
|
60
|
+
self.assertIn("reason=rollout-replaced", self.pool.log_lines()[-1])
|
|
61
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
62
|
+
|
|
63
|
+
def test_truncated_rollout_refuses_without_stopping_client(self):
|
|
64
|
+
watcher, runner, path, _ = self.resumed_watcher()
|
|
65
|
+
path.write_bytes(path.read_bytes().splitlines(keepends=True)[0])
|
|
66
|
+
self.assertEqual(watcher.run(), 0)
|
|
67
|
+
self.assertIn("reason=rollout-replaced", self.pool.log_lines()[-1])
|
|
68
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
69
|
+
|
|
70
|
+
def test_absent_cursor_preserves_legacy_behavior(self):
|
|
71
|
+
_, _, path, _ = self.resumed_watcher()
|
|
72
|
+
for cursor in (None, ""):
|
|
73
|
+
tail = fixtures.ar.JsonlTail(path)
|
|
74
|
+
self.assertTrue(apply_cursor(tail, cursor))
|
|
75
|
+
self.assertEqual(tail.off, 0)
|
|
76
|
+
self.assertEqual(len(tail.read_lines()), 2)
|
|
77
|
+
|
|
78
|
+
def test_malformed_present_cursor_refuses_instead_of_replaying_history(self):
|
|
79
|
+
_, _, path, _ = self.resumed_watcher()
|
|
80
|
+
for cursor in ("1::2", "1:2:3", "1", "-1:2", "1:-2", "1:a", "9" * 21 + ":2"):
|
|
81
|
+
with self.subTest(cursor=cursor):
|
|
82
|
+
self.assertIsNone(cursor_value(cursor))
|
|
83
|
+
self.assertFalse(apply_cursor(fixtures.ar.JsonlTail(path), cursor))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capability detection uses disposable local CLIs, never account credentials."""
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LIB = Path(__file__).resolve().parents[1] / "lib" / "codex_autoresume_mode.py"
|
|
14
|
+
SPEC = importlib.util.spec_from_file_location("codex_autoresume_mode", LIB)
|
|
15
|
+
mode = importlib.util.module_from_spec(SPEC)
|
|
16
|
+
SPEC.loader.exec_module(mode)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NativeModeTests(unittest.TestCase):
|
|
20
|
+
def setUp(self):
|
|
21
|
+
self.scratch = tempfile.TemporaryDirectory(prefix="codex-ar-mode-")
|
|
22
|
+
self.addCleanup(self.scratch.cleanup)
|
|
23
|
+
self.root = Path(self.scratch.name)
|
|
24
|
+
self.executable = self.root / "codex"
|
|
25
|
+
|
|
26
|
+
def executable_with(self, body):
|
|
27
|
+
self.executable.write_text("#!" + sys.executable + "\n" + body)
|
|
28
|
+
self.executable.chmod(0o700)
|
|
29
|
+
return str(self.executable)
|
|
30
|
+
|
|
31
|
+
def test_advertised_option_is_detected_and_cli_only_outputs_flag(self):
|
|
32
|
+
executable = self.executable_with(
|
|
33
|
+
"import sys\nassert sys.argv[1:] == ['--help']\n"
|
|
34
|
+
"print('Options:\\n --no-daemon\\n Run without shared server')\n"
|
|
35
|
+
)
|
|
36
|
+
self.assertTrue(mode.supports_no_daemon(executable))
|
|
37
|
+
result = subprocess.run([sys.executable, "-I", str(LIB), executable],
|
|
38
|
+
capture_output=True, text=True, timeout=5)
|
|
39
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "--no-daemon\n", ""))
|
|
40
|
+
|
|
41
|
+
def test_old_cli_and_mentions_without_option_are_not_supported(self):
|
|
42
|
+
for help_text in ("Options:\n --help", "Use --no-daemon if available", " --no-daemon-other"):
|
|
43
|
+
with self.subTest(help_text=help_text):
|
|
44
|
+
executable = self.executable_with("print(%r)\n" % help_text)
|
|
45
|
+
self.assertFalse(mode.supports_no_daemon(executable))
|
|
46
|
+
|
|
47
|
+
def test_failed_help_and_missing_binary_fail_open(self):
|
|
48
|
+
executable = self.executable_with("print(' --no-daemon')\nraise SystemExit(2)\n")
|
|
49
|
+
self.assertFalse(mode.supports_no_daemon(executable))
|
|
50
|
+
self.assertFalse(mode.supports_no_daemon(str(self.root / "missing")))
|
|
51
|
+
|
|
52
|
+
def test_updates_are_checked_without_stale_capability_cache(self):
|
|
53
|
+
executable = self.executable_with("print(' --help')\n")
|
|
54
|
+
self.assertFalse(mode.supports_no_daemon(executable))
|
|
55
|
+
self.executable_with("print(' --no-daemon')\n")
|
|
56
|
+
self.assertTrue(mode.supports_no_daemon(executable))
|
|
57
|
+
self.executable_with("print(' --help')\n")
|
|
58
|
+
self.assertFalse(mode.supports_no_daemon(executable))
|
|
59
|
+
|
|
60
|
+
def test_large_output_and_stderr_do_not_advertise_option(self):
|
|
61
|
+
executable = self.executable_with(
|
|
62
|
+
"import sys\nsys.stdout.write('x' * %d + '\\n --no-daemon\\n')\n"
|
|
63
|
+
"sys.stderr.write(' --no-daemon\\n')\n" % mode.MAX_HELP_BYTES
|
|
64
|
+
)
|
|
65
|
+
self.assertFalse(mode.supports_no_daemon(executable))
|
|
66
|
+
|
|
67
|
+
def test_timeout_stops_probe_and_its_child_without_touching_other_processes(self):
|
|
68
|
+
marker = self.root / "child-finished"
|
|
69
|
+
executable = self.executable_with(
|
|
70
|
+
"import os, time\n"
|
|
71
|
+
"if os.fork() == 0:\n"
|
|
72
|
+
" time.sleep(0.5)\n"
|
|
73
|
+
" open(%r, 'w').write('unexpected')\n"
|
|
74
|
+
" os._exit(0)\n"
|
|
75
|
+
"time.sleep(20)\n" % str(marker)
|
|
76
|
+
)
|
|
77
|
+
other = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(20)"])
|
|
78
|
+
self.addCleanup(other.wait)
|
|
79
|
+
self.addCleanup(other.terminate)
|
|
80
|
+
started = time.monotonic()
|
|
81
|
+
self.assertFalse(mode.supports_no_daemon(executable, timeout=0.1))
|
|
82
|
+
self.assertLess(time.monotonic() - started, 2)
|
|
83
|
+
time.sleep(0.6)
|
|
84
|
+
self.assertFalse(marker.exists())
|
|
85
|
+
self.assertIsNone(other.poll())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Receipt checks require newly persisted user input in the bound conversation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import signal
|
|
6
|
+
import tempfile
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import test_autoresume as fixtures
|
|
12
|
+
from autoresume_receipt import codex_prompt_record, confirm_prompt, receipt_tail
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReceiptTests(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
temporary = tempfile.TemporaryDirectory()
|
|
18
|
+
self.addCleanup(temporary.cleanup)
|
|
19
|
+
self.path = Path(temporary.name) / "rollout.jsonl"
|
|
20
|
+
self.path.write_bytes(b"{}\n")
|
|
21
|
+
self.prompt = fixtures.ar.resume_prompt("quota")
|
|
22
|
+
self.record = json.dumps({"type": "event_msg", "payload": {
|
|
23
|
+
"type": "user_message", "message": self.prompt}}).encode() + b"\n"
|
|
24
|
+
self.runner = fixtures.FakeRunner(100, 4242, 777, "/fake/codex")
|
|
25
|
+
self.watcher = SimpleNamespace(r=self.runner, switched_off=lambda: False,
|
|
26
|
+
cfg=SimpleNamespace(prompt_wait=0.5, poll=0.05))
|
|
27
|
+
|
|
28
|
+
def test_new_exact_user_records_confirm(self):
|
|
29
|
+
response = json.dumps({"type": "response_item", "payload": {
|
|
30
|
+
"type": "message", "role": "user", "content": [
|
|
31
|
+
{"type": "input_text", "text": self.prompt}]}}).encode() + b"\n"
|
|
32
|
+
for raw in (self.record, response):
|
|
33
|
+
with self.subTest(raw=raw):
|
|
34
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
35
|
+
self.path.write_bytes(self.path.read_bytes() + raw)
|
|
36
|
+
self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
|
|
37
|
+
|
|
38
|
+
def test_wrong_quoted_and_malformed_records_do_not_confirm(self):
|
|
39
|
+
records = [None, [], {}, {"type": "event_msg", "payload": []}]
|
|
40
|
+
records += [{"type": "event_msg", "payload": {"type": kind, "message": self.prompt}}
|
|
41
|
+
for kind in ("agent_message", "task_complete", "error")]
|
|
42
|
+
records += [{"type": "response_item", "payload": {
|
|
43
|
+
"type": "message", "role": role, "content": [
|
|
44
|
+
{"type": "input_text", "text": self.prompt}]}}
|
|
45
|
+
for role in ("assistant", "tool", "system")]
|
|
46
|
+
records += [{"type": "event_msg", "payload": {"type": "user_message", "message": text}}
|
|
47
|
+
for text in (self.prompt + " extra", "quoted: " + self.prompt, self.prompt[:40])]
|
|
48
|
+
for record in records:
|
|
49
|
+
self.assertFalse(codex_prompt_record(json.dumps(record), self.prompt), record)
|
|
50
|
+
for raw in (b"{", b"\xff", None):
|
|
51
|
+
self.assertFalse(codex_prompt_record(raw, self.prompt))
|
|
52
|
+
|
|
53
|
+
def test_historical_identical_prompt_is_not_a_receipt(self):
|
|
54
|
+
self.path.write_bytes(self.record)
|
|
55
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
56
|
+
self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
|
|
57
|
+
self.assertGreaterEqual(self.runner.now(), 100.5)
|
|
58
|
+
|
|
59
|
+
def test_partial_json_line_waits_for_completion(self):
|
|
60
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
61
|
+
prefix = self.path.read_bytes()
|
|
62
|
+
self.path.write_bytes(prefix + self.record[:-1])
|
|
63
|
+
self.runner.at(100.2, lambda: self.path.write_bytes(prefix + self.record))
|
|
64
|
+
self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
|
|
65
|
+
self.assertGreaterEqual(self.runner.now(), 100.2)
|
|
66
|
+
|
|
67
|
+
def test_delayed_new_prompt_is_confirmed(self):
|
|
68
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
69
|
+
self.runner.at(100.3, lambda: self.path.write_bytes(self.path.read_bytes() + self.record))
|
|
70
|
+
self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
|
|
71
|
+
self.assertGreaterEqual(self.runner.now(), 100.3)
|
|
72
|
+
|
|
73
|
+
def test_replaced_or_truncated_rollout_is_rejected(self):
|
|
74
|
+
for replace in (False, True):
|
|
75
|
+
with self.subTest(replace=replace):
|
|
76
|
+
self.path.write_bytes(self.record * 3)
|
|
77
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
78
|
+
replacement = self.path.with_suffix(".replacement") if replace else self.path
|
|
79
|
+
replacement.write_bytes(self.record)
|
|
80
|
+
if replace:
|
|
81
|
+
replacement.replace(self.path)
|
|
82
|
+
self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
|
|
83
|
+
|
|
84
|
+
def test_missing_file_and_kill_switch_stop_waiting(self):
|
|
85
|
+
tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
|
|
86
|
+
self.watcher.switched_off = lambda: True
|
|
87
|
+
self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
|
|
88
|
+
self.path.unlink()
|
|
89
|
+
self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
|
|
90
|
+
self.assertEqual(self.runner.now(), 100)
|
|
91
|
+
|
|
92
|
+
def test_receipt_requires_an_existing_rollout(self):
|
|
93
|
+
self.assertIsNone(receipt_tail(None, fixtures.ar.JsonlTail))
|
|
94
|
+
self.assertIsNone(receipt_tail(self.path.with_name("missing"), fixtures.ar.JsonlTail))
|
|
95
|
+
|
|
96
|
+
def test_consumed_token_without_prompt_never_reports_switch(self):
|
|
97
|
+
pool = fixtures.CodexWatcherTests(methodName="runTest")
|
|
98
|
+
pool.setUp()
|
|
99
|
+
self.addCleanup(pool.tearDown)
|
|
100
|
+
records = [fixtures.cx_meta(pool.launched - 3600, cwd=str(pool.work)),
|
|
101
|
+
fixtures.cx_event(pool.launched - 10, {"type": "user_message", "message": self.prompt}),
|
|
102
|
+
fixtures.cx_error(pool.t0, "usage_limit_exceeded")]
|
|
103
|
+
with mock.patch.object(fixtures, "append"):
|
|
104
|
+
rc, _, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records,
|
|
105
|
+
env={"CODEX_MULTIACC_AR_PROMPT_WAIT": "0.5"})
|
|
106
|
+
self.assertEqual(rc, 0)
|
|
107
|
+
self.assertTrue(runner.consumed)
|
|
108
|
+
self.assertEqual(pool.events(), ["watch", "detect", "giveup"])
|
|
109
|
+
self.assertIn("reason=prompt", pool.log_lines()[-1])
|
|
110
|
+
self.assertIn('resume="codex resume ' + fixtures.CX_SID, pool.log_lines()[-1])
|
|
111
|
+
self.assertEqual(runner.signals, [(pool.PID, signal.SIGTERM)])
|
|
112
|
+
self.assertEqual((len(runner.sends), len(runner.notices)), (4, 1))
|
|
113
|
+
self.assertEqual(pool.relaunch_files(), [])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|