agent-runtime-py 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_runtime_py-0.2.2/CHANGELOG.md +21 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/PKG-INFO +1 -1
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/pyproject.toml +1 -1
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/claude.py +24 -8
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/worker.py +36 -2
- agent_runtime_py-0.2.2/tests/test_claude_runtime.py +345 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/uv.lock +3 -3
- agent_runtime_py-0.2.0/CHANGELOG.md +0 -8
- agent_runtime_py-0.2.0/tests/test_claude_runtime.py +0 -166
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.github/workflows/ci.yml +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.github/workflows/release.yml +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.gitignore +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/LICENSE +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/README.md +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/__init__.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/bridge.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/hooks.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/monitor_worker.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/monitors.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/operations.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/sessions.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/store.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/tests/test_hooks.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/tests/test_monitors.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Add `LaunchOptions.effort` (`low`, `medium`, `high`, `xhigh` or `max`), passed to native Claude.
|
|
6
|
+
- A worker that fails adds `native_stderr` to its result: the last lines (at most 20, each at most 500 characters) that native Claude wrote to stderr since its last result. They are often the reason of a failed start, where the SDK's error only says to check stderr. They are not redacted; a consumer decides whether to show them.
|
|
7
|
+
- Fix: `LaunchOptions.claude` and the `claude` found on PATH are used as given (made absolute), not as their link target. A version manager's shim (a link to the manager) was resolved to the manager, which then did not run Claude. A path that is not a file, also a directory, now raises `ValueError`; a missing path raised `FileNotFoundError` before.
|
|
8
|
+
- A repeated launch ID no longer compares the path of the Claude executable, so a launch that 0.2.1 recorded can be repeated.
|
|
9
|
+
|
|
10
|
+
## 0.2.1
|
|
11
|
+
|
|
12
|
+
- Fix: a Claude input that native Claude folded into a turn started by something else (a peer message, a background task) never settled, because that turn's result carries the other origin. The worker then took no further input and never exited on idle. A result now settles the active input when the input's echo arrived before it.
|
|
13
|
+
- Fix: a turn that native Claude runs for another origin (a peer message) counts as work: the worker reports `turn.started` when that message arrives, not at the first assistant text, and does not exit on idle while the turn runs.
|
|
14
|
+
- Fix: the Claude worker starts with `-P`, so the session's working folder is not on its import path.
|
|
15
|
+
|
|
16
|
+
## 0.2.0
|
|
17
|
+
|
|
18
|
+
- Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
|
|
19
|
+
- Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
|
|
20
|
+
- Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
|
|
21
|
+
- Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
|
|
@@ -33,6 +33,7 @@ class LaunchOptions:
|
|
|
33
33
|
permission_mode: str = "default"
|
|
34
34
|
allowed_tools: tuple[str, ...] = ()
|
|
35
35
|
model: str | None = None
|
|
36
|
+
effort: str | None = None
|
|
36
37
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
38
|
|
|
38
39
|
def configuration(self) -> dict[str, Any]:
|
|
@@ -48,18 +49,34 @@ class LaunchOptions:
|
|
|
48
49
|
"auto",
|
|
49
50
|
}:
|
|
50
51
|
raise ValueError("unknown Claude permission mode")
|
|
52
|
+
if self.effort is not None and self.effort not in ("low", "medium", "high", "xhigh", "max"):
|
|
53
|
+
raise ValueError("unknown Claude effort level")
|
|
51
54
|
cwd = self.cwd.expanduser().resolve(strict=True)
|
|
52
55
|
if not cwd.is_dir():
|
|
53
56
|
raise ValueError("cwd must be a directory")
|
|
54
57
|
executable = str(self.claude) if self.claude else shutil.which("claude")
|
|
55
58
|
if not executable:
|
|
56
59
|
raise ValueError("Claude Code executable not found")
|
|
60
|
+
# The path as given, not its link target: a version manager's shim is a link to the
|
|
61
|
+
# manager, which chooses the program by the name it was called with.
|
|
62
|
+
claude = Path(executable).expanduser().absolute()
|
|
63
|
+
if not claude.is_file():
|
|
64
|
+
raise ValueError(f"Claude Code executable not found: {claude}")
|
|
57
65
|
value = asdict(self)
|
|
58
66
|
value.pop("environment")
|
|
59
|
-
|
|
67
|
+
if self.effort is None:
|
|
68
|
+
value.pop("effort") # as a record of a version without the field
|
|
69
|
+
value.update(cwd=str(cwd), claude=str(claude))
|
|
60
70
|
return json.loads(json.dumps(value))
|
|
61
71
|
|
|
62
72
|
|
|
73
|
+
def launch_identity(configuration: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""The part of a launch configuration that a repeated launch ID must repeat. The path of
|
|
75
|
+
the Claude executable is not part of it: its spelling changes with the installation and
|
|
76
|
+
between versions of this package (0.2.1 stored the link's target)."""
|
|
77
|
+
return {key: value for key, value in configuration.items() if key != "claude"}
|
|
78
|
+
|
|
79
|
+
|
|
63
80
|
def process_alive(value: dict[str, Any]) -> bool:
|
|
64
81
|
"""PID reuse must never attach a client to an unrelated process."""
|
|
65
82
|
if not value.get("pid") or not value.get("process_created_at"):
|
|
@@ -145,15 +162,11 @@ class ClaudeRuntime:
|
|
|
145
162
|
}
|
|
146
163
|
if not self.store.create_execution(value):
|
|
147
164
|
existing = self.inspect(execution_id)
|
|
148
|
-
for key in (
|
|
149
|
-
"initial_message_id",
|
|
150
|
-
"initial_digest",
|
|
151
|
-
"resume",
|
|
152
|
-
"fork",
|
|
153
|
-
"configuration",
|
|
154
|
-
):
|
|
165
|
+
for key in ("initial_message_id", "initial_digest", "resume", "fork"):
|
|
155
166
|
if existing[key] != value[key]:
|
|
156
167
|
raise ValueError(f"launch ID already used with different {key}")
|
|
168
|
+
if launch_identity(existing["configuration"]) != launch_identity(configuration):
|
|
169
|
+
raise ValueError("launch ID already used with different configuration")
|
|
157
170
|
return existing
|
|
158
171
|
self.store.accept(session_id, execution_id, message_id, text)
|
|
159
172
|
environment = {**os.environ, **options.environment}
|
|
@@ -177,6 +190,9 @@ class ClaudeRuntime:
|
|
|
177
190
|
child = subprocess.Popen(
|
|
178
191
|
[
|
|
179
192
|
sys.executable,
|
|
193
|
+
# The worker's cwd is the session's folder, which the agent
|
|
194
|
+
# writes; it must not be on the worker's import path.
|
|
195
|
+
"-P",
|
|
180
196
|
"-m",
|
|
181
197
|
"agent_runtime.worker",
|
|
182
198
|
str(self.store.root),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import collections
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
9
|
import signal
|
|
@@ -45,6 +46,14 @@ class Worker:
|
|
|
45
46
|
self.last_activity = time.monotonic()
|
|
46
47
|
self.background_tasks: set[str] = set()
|
|
47
48
|
self.presentation_started = False
|
|
49
|
+
# The last lines that native Claude wrote to stderr since its last result. When the
|
|
50
|
+
# worker fails, they go into its result apart from the error: they are often the
|
|
51
|
+
# reason (the SDK's error only says to check stderr), but not always.
|
|
52
|
+
self.stderr: collections.deque[str] = collections.deque(maxlen=20)
|
|
53
|
+
|
|
54
|
+
def native_stderr(self, line: str) -> None:
|
|
55
|
+
log.debug("claude: %s", line)
|
|
56
|
+
self.stderr.append(line[:500])
|
|
48
57
|
|
|
49
58
|
def emit(self, kind: str, **fields: Any) -> None:
|
|
50
59
|
self.store.append(self.execution_id, self.session_id, {"type": kind, **fields})
|
|
@@ -100,6 +109,17 @@ class Worker:
|
|
|
100
109
|
if isinstance(message, UserMessage) and message.uuid:
|
|
101
110
|
if admitted := self.store.admit(self.execution_id, message.uuid):
|
|
102
111
|
self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
|
|
112
|
+
if self.active and self.active["id"] == admitted:
|
|
113
|
+
self.active["acknowledged"] = True
|
|
114
|
+
elif (
|
|
115
|
+
(message.origin or {}).get("kind", "human") != "human"
|
|
116
|
+
and message.parent_tool_use_id is None
|
|
117
|
+
and not self.presentation_started
|
|
118
|
+
):
|
|
119
|
+
# A message from another origin (a peer session, a background task)
|
|
120
|
+
# starts a turn that no input of this runtime asked for.
|
|
121
|
+
self.emit("turn.started", origin="native")
|
|
122
|
+
self.presentation_started = True
|
|
103
123
|
elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
|
|
104
124
|
if not self.presentation_started:
|
|
105
125
|
self.emit("turn.started", origin="native")
|
|
@@ -112,8 +132,17 @@ class Worker:
|
|
|
112
132
|
elif isinstance(message, ResultMessage):
|
|
113
133
|
if message.session_id != self.session_id:
|
|
114
134
|
raise RuntimeError("native Claude returned a different session ID")
|
|
135
|
+
self.stderr.clear() # what it wrote before a result is not about a later failure
|
|
115
136
|
human = message.origin is None or message.origin.get("kind") == "human"
|
|
116
|
-
|
|
137
|
+
# Native Claude can fold an input into a turn that something else started
|
|
138
|
+
# (a peer message, a background task); that turn's result carries the other
|
|
139
|
+
# origin. The turn that follows the input's echo is the one that consumed it.
|
|
140
|
+
# A non-human result before the echo belongs to an earlier turn.
|
|
141
|
+
active = (
|
|
142
|
+
self.active
|
|
143
|
+
if self.active and (human or self.active.get("acknowledged"))
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
117
146
|
status = "failed" if message.is_error else "completed"
|
|
118
147
|
if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
|
|
119
148
|
status = "interrupted"
|
|
@@ -170,6 +199,7 @@ class Worker:
|
|
|
170
199
|
permission_mode=policy["permission_mode"],
|
|
171
200
|
allowed_tools=policy["allowed_tools"],
|
|
172
201
|
model=policy["model"],
|
|
202
|
+
effort=policy.get("effort"), # absent in the record of an older version
|
|
173
203
|
setting_sources=["user", "project", "local"],
|
|
174
204
|
system_prompt={
|
|
175
205
|
"type": "preset",
|
|
@@ -177,7 +207,7 @@ class Worker:
|
|
|
177
207
|
"append": policy["instructions"] or "",
|
|
178
208
|
},
|
|
179
209
|
can_use_tool=self.permission,
|
|
180
|
-
stderr=
|
|
210
|
+
stderr=self.native_stderr,
|
|
181
211
|
extra_args={"replay-user-messages": None},
|
|
182
212
|
session_id=self.session_id
|
|
183
213
|
if not self.execution["resume"] or self.execution["fork"]
|
|
@@ -243,6 +273,8 @@ class Worker:
|
|
|
243
273
|
if (
|
|
244
274
|
timeout
|
|
245
275
|
and self.active is None
|
|
276
|
+
# A turn that native Claude runs for another origin is work too.
|
|
277
|
+
and not self.presentation_started
|
|
246
278
|
and not self.background_tasks
|
|
247
279
|
and time.monotonic() - self.last_activity >= timeout
|
|
248
280
|
and not any(
|
|
@@ -256,6 +288,8 @@ class Worker:
|
|
|
256
288
|
except Exception as error:
|
|
257
289
|
log.exception("Claude worker failed")
|
|
258
290
|
result = {"status": "failed", "error": str(error)}
|
|
291
|
+
if self.stderr:
|
|
292
|
+
result["native_stderr"] = list(self.stderr)
|
|
259
293
|
finally:
|
|
260
294
|
self.stopping.set()
|
|
261
295
|
try:
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Exercise the real SDK and detached processes against a deterministic native boundary."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from agent_runtime import ClaudeRuntime, LaunchOptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def native(tmp_path: Path) -> Path:
|
|
16
|
+
cli = tmp_path / "claude"
|
|
17
|
+
cli.write_text(
|
|
18
|
+
f"#!{sys.executable}\n"
|
|
19
|
+
+ r"""
|
|
20
|
+
import json, os, sys
|
|
21
|
+
if "--version" in sys.argv:
|
|
22
|
+
print("2.1.281 (Claude Code)")
|
|
23
|
+
raise SystemExit(0)
|
|
24
|
+
if os.environ.get("FAKE_CLAUDE_ARGV"):
|
|
25
|
+
with open(os.environ["FAKE_CLAUDE_ARGV"], "w") as log:
|
|
26
|
+
json.dump(sys.argv, log)
|
|
27
|
+
if os.environ.get("FAKE_CLAUDE_FAILS"):
|
|
28
|
+
for number in range(30):
|
|
29
|
+
print(f"line {number} " + "x" * 600, file=sys.stderr, flush=True)
|
|
30
|
+
print("Invalid API key - Please run /login", file=sys.stderr, flush=True)
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
print("warning: a line at the start", file=sys.stderr, flush=True)
|
|
33
|
+
def emit(value):
|
|
34
|
+
print(json.dumps(value), flush=True)
|
|
35
|
+
session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
|
|
36
|
+
PEER = {"kind":"peer","from":"unknown"}
|
|
37
|
+
def result(text, origin={"kind":"human"}):
|
|
38
|
+
emit({"type":"result", "subtype":"success", "is_error":False,
|
|
39
|
+
"duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
|
|
40
|
+
"result":text,"origin":origin,"usage":{}})
|
|
41
|
+
pending = None
|
|
42
|
+
for line in sys.stdin:
|
|
43
|
+
message = json.loads(line)
|
|
44
|
+
if message["type"] == "control_request":
|
|
45
|
+
emit({"type":"control_response","response":{"subtype":"success",
|
|
46
|
+
"request_id":message["request_id"],"response":{}}})
|
|
47
|
+
elif message["type"] == "user":
|
|
48
|
+
text = message["message"]["content"]
|
|
49
|
+
with open(os.environ["FAKE_CLAUDE_LOG"], "a") as log:
|
|
50
|
+
log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
|
|
51
|
+
if text == "crash-before-echo":
|
|
52
|
+
raise SystemExit(23)
|
|
53
|
+
if text == "folded-into-peer-turn":
|
|
54
|
+
# Native Claude, observed 2026-11-10: a peer message's turn ends, a second peer
|
|
55
|
+
# message starts a turn, the queued human input joins that turn, and the turn's
|
|
56
|
+
# result carries the peer origin.
|
|
57
|
+
result("first peer turn", PEER)
|
|
58
|
+
emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
|
|
59
|
+
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
60
|
+
result("answer to both", PEER)
|
|
61
|
+
continue
|
|
62
|
+
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
63
|
+
if text == "wrong-session":
|
|
64
|
+
# A failure of the worker's own check, late in a session, with no stderr line.
|
|
65
|
+
emit({"type":"result", "subtype":"success", "is_error":False, "duration_ms":1,
|
|
66
|
+
"duration_api_ms":1, "num_turns":1, "session_id":"another-session",
|
|
67
|
+
"result":"x", "origin":{"kind":"human"}, "usage":{}})
|
|
68
|
+
continue
|
|
69
|
+
if text == "then-a-peer-turn":
|
|
70
|
+
# The input's own turn ends; a peer message then starts a turn that works
|
|
71
|
+
# in silence for longer than the idle timeout.
|
|
72
|
+
import time
|
|
73
|
+
result("echo: " + text)
|
|
74
|
+
emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
|
|
75
|
+
time.sleep(1.5)
|
|
76
|
+
result("peer turn done", PEER)
|
|
77
|
+
continue
|
|
78
|
+
if text == "permission":
|
|
79
|
+
pending = text
|
|
80
|
+
emit({"type":"control_request","request_id":"permission-1",
|
|
81
|
+
"request":{"subtype":"can_use_tool","tool_name":"Bash",
|
|
82
|
+
"input":{"command":"touch example"},"tool_use_id":"tool-1"}})
|
|
83
|
+
elif text != "hold":
|
|
84
|
+
result("echo: " + text)
|
|
85
|
+
elif message["type"] == "control_response" and pending:
|
|
86
|
+
result(json.dumps(message["response"]["response"]))
|
|
87
|
+
pending = None
|
|
88
|
+
"""
|
|
89
|
+
)
|
|
90
|
+
cli.chmod(0o700)
|
|
91
|
+
return cli
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def wait_for(predicate):
|
|
95
|
+
for _ in range(200):
|
|
96
|
+
if value := predicate():
|
|
97
|
+
return value
|
|
98
|
+
await asyncio.sleep(0.05)
|
|
99
|
+
raise AssertionError("condition did not become true")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def options(tmp_path: Path, native: Path) -> LaunchOptions:
|
|
103
|
+
return LaunchOptions(
|
|
104
|
+
cwd=tmp_path,
|
|
105
|
+
claude=native,
|
|
106
|
+
idle_timeout_seconds=0,
|
|
107
|
+
environment={
|
|
108
|
+
"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
|
|
109
|
+
"PRIVATE_TEST_SECRET": "do-not-persist-this",
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_reconnect_duplicate_admission_and_explicit_resume(tmp_path, native):
|
|
115
|
+
root = tmp_path / "runtime"
|
|
116
|
+
runtime = ClaudeRuntime(root)
|
|
117
|
+
opts = options(tmp_path, native)
|
|
118
|
+
first = await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1")
|
|
119
|
+
sid, eid = first["session_id"], first["execution_id"]
|
|
120
|
+
try:
|
|
121
|
+
await wait_for(
|
|
122
|
+
lambda: any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
|
|
123
|
+
)
|
|
124
|
+
# A new client is the entire application-service restart contract.
|
|
125
|
+
runtime = ClaudeRuntime(root)
|
|
126
|
+
assert runtime.inspect(eid)["result"] is None
|
|
127
|
+
assert runtime.store.receipt(sid, "m1")["status"] == "delivered"
|
|
128
|
+
assert (await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1"))[
|
|
129
|
+
"execution_id"
|
|
130
|
+
] == eid
|
|
131
|
+
with pytest.raises(ValueError, match="different"):
|
|
132
|
+
await runtime.start("changed", message_id="m1", options=opts, launch_id="launch1")
|
|
133
|
+
await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
134
|
+
await wait_for(lambda: runtime.store.receipt(sid, "m2")["status"] == "delivered")
|
|
135
|
+
receipt = await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
136
|
+
assert receipt["status"] == "delivered"
|
|
137
|
+
with pytest.raises(ValueError):
|
|
138
|
+
await runtime.submit(sid, "different", message_id="m2", options=opts, resume=False)
|
|
139
|
+
assert "do-not-persist-this" not in runtime.store.path.read_bytes().decode(errors="ignore")
|
|
140
|
+
assert os.stat(root).st_mode & 0o777 == 0o700
|
|
141
|
+
assert os.stat(runtime.store.path).st_mode & 0o777 == 0o600
|
|
142
|
+
assert (await runtime.stop(eid))["result"]["status"] == "stopped"
|
|
143
|
+
rejected = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=False)
|
|
144
|
+
assert rejected["status"] == "rejected"
|
|
145
|
+
resumed = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=True)
|
|
146
|
+
eid = resumed["execution_id"]
|
|
147
|
+
assert eid != first["execution_id"]
|
|
148
|
+
await wait_for(lambda: runtime.store.receipt(sid, "m3")["status"] == "delivered")
|
|
149
|
+
recorded = [
|
|
150
|
+
json.loads(line) for line in (tmp_path / "inputs.jsonl").read_text().splitlines()
|
|
151
|
+
]
|
|
152
|
+
assert [r["text"] for r in recorded] == ["hello", "next", "later"]
|
|
153
|
+
assert {r["session"] for r in recorded} == {sid}
|
|
154
|
+
finally:
|
|
155
|
+
await runtime.stop(eid)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_lost_native_ack_is_uncertain_and_never_replayed(tmp_path, native):
|
|
159
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
160
|
+
opts = options(tmp_path, native)
|
|
161
|
+
value = await runtime.start(
|
|
162
|
+
"crash-before-echo", message_id="m1", options=opts, launch_id="crash"
|
|
163
|
+
)
|
|
164
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
165
|
+
try:
|
|
166
|
+
await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
167
|
+
assert runtime.store.receipt(sid, "m1")["status"] == "uncertain"
|
|
168
|
+
saved = await runtime.submit(
|
|
169
|
+
sid, "crash-before-echo", message_id="m1", options=opts, resume=True
|
|
170
|
+
)
|
|
171
|
+
assert saved["status"] == "uncertain"
|
|
172
|
+
assert len((tmp_path / "inputs.jsonl").read_text().splitlines()) == 1
|
|
173
|
+
finally:
|
|
174
|
+
await runtime.stop(eid)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
|
|
178
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
179
|
+
value = await runtime.start(
|
|
180
|
+
"permission", message_id="m1", options=options(tmp_path, native), launch_id="permission"
|
|
181
|
+
)
|
|
182
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
183
|
+
try:
|
|
184
|
+
requests = await wait_for(lambda: runtime.store.interactions(eid))
|
|
185
|
+
assert requests[0]["params"]["tool"] == "Bash"
|
|
186
|
+
assert not any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
|
|
187
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
188
|
+
runtime.store.answer(eid, requests[0]["id"], {"decision": "deny"})
|
|
189
|
+
settled = await wait_for(
|
|
190
|
+
lambda: [
|
|
191
|
+
e["event"]
|
|
192
|
+
for e in runtime.store.events(sid)
|
|
193
|
+
if e["event"]["type"] == "work.settled"
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
assert json.loads(settled[0]["message"])["behavior"] == "deny"
|
|
197
|
+
with pytest.raises(ValueError):
|
|
198
|
+
runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
|
|
199
|
+
finally:
|
|
200
|
+
await runtime.stop(eid)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def test_input_folded_into_a_peer_turn_settles_with_that_turn(tmp_path, native):
|
|
204
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
205
|
+
opts = options(tmp_path, native)
|
|
206
|
+
value = await runtime.start(
|
|
207
|
+
"folded-into-peer-turn", message_id="m1", options=opts, launch_id="folded"
|
|
208
|
+
)
|
|
209
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
210
|
+
|
|
211
|
+
def settled():
|
|
212
|
+
return [
|
|
213
|
+
e["event"] for e in runtime.store.events(sid) if e["event"]["type"] == "work.settled"
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
await wait_for(lambda: len(settled()) >= 2)
|
|
218
|
+
# The first result came before the input's echo, so it is not the input's result.
|
|
219
|
+
assert [event["message_ids"] for event in settled()] == [[], ["m1"]]
|
|
220
|
+
assert settled()[1]["message"] == "answer to both"
|
|
221
|
+
# The worker is free again: it takes the next input.
|
|
222
|
+
await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
223
|
+
await wait_for(lambda: len(settled()) >= 3)
|
|
224
|
+
assert settled()[2]["message_ids"] == ["m2"]
|
|
225
|
+
finally:
|
|
226
|
+
await runtime.stop(eid)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def test_a_turn_for_another_origin_is_not_ended_by_the_idle_timeout(tmp_path, native):
|
|
230
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
231
|
+
opts = LaunchOptions(
|
|
232
|
+
cwd=tmp_path,
|
|
233
|
+
claude=native,
|
|
234
|
+
idle_timeout_seconds=0.5,
|
|
235
|
+
environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
|
|
236
|
+
)
|
|
237
|
+
value = await runtime.start("then-a-peer-turn", message_id="m1", options=opts, launch_id="peer")
|
|
238
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
239
|
+
try:
|
|
240
|
+
await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
241
|
+
events = [e["event"] for e in runtime.store.events(sid)]
|
|
242
|
+
kinds = [
|
|
243
|
+
e["type"]
|
|
244
|
+
for e in events
|
|
245
|
+
if e["type"] in {"turn.started", "work.settled", "worker.exited"}
|
|
246
|
+
]
|
|
247
|
+
# The worker saw the peer turn start and waited for its result before its idle exit.
|
|
248
|
+
assert kinds == [
|
|
249
|
+
"turn.started",
|
|
250
|
+
"work.settled",
|
|
251
|
+
"turn.started",
|
|
252
|
+
"work.settled",
|
|
253
|
+
"worker.exited",
|
|
254
|
+
]
|
|
255
|
+
assert events[-1]["reason"] == "idle_timeout"
|
|
256
|
+
settled = [e for e in events if e["type"] == "work.settled"]
|
|
257
|
+
assert [e["message_ids"] for e in settled] == [["m1"], []]
|
|
258
|
+
assert settled[1]["message"] == "peer turn done"
|
|
259
|
+
finally:
|
|
260
|
+
await runtime.stop(eid)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def test_effort_reaches_native_claude_and_a_link_to_it_is_not_resolved(tmp_path, native):
|
|
264
|
+
link = tmp_path / "shim"
|
|
265
|
+
link.symlink_to(native)
|
|
266
|
+
opts = LaunchOptions(
|
|
267
|
+
cwd=tmp_path,
|
|
268
|
+
claude=link,
|
|
269
|
+
effort="high",
|
|
270
|
+
idle_timeout_seconds=0,
|
|
271
|
+
environment={
|
|
272
|
+
"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
|
|
273
|
+
"FAKE_CLAUDE_ARGV": str(tmp_path / "argv.json"),
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
assert opts.configuration()["claude"] == str(link)
|
|
277
|
+
with pytest.raises(ValueError, match="effort"):
|
|
278
|
+
LaunchOptions(cwd=tmp_path, claude=native, effort="extreme").configuration()
|
|
279
|
+
with pytest.raises(ValueError, match="not found"):
|
|
280
|
+
LaunchOptions(cwd=tmp_path, claude=tmp_path / "missing").configuration()
|
|
281
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
282
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="effort")
|
|
283
|
+
try:
|
|
284
|
+
await wait_for(
|
|
285
|
+
lambda: runtime.store.receipt(value["session_id"], "m1")["status"] == "delivered"
|
|
286
|
+
)
|
|
287
|
+
argv = json.loads((tmp_path / "argv.json").read_text())
|
|
288
|
+
assert argv[0] == str(link) and argv[argv.index("--effort") + 1] == "high"
|
|
289
|
+
finally:
|
|
290
|
+
await runtime.stop(value["execution_id"])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def test_a_native_claude_that_fails_at_its_start_reports_what_it_wrote(tmp_path, native):
|
|
294
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
295
|
+
opts = options(tmp_path, native)
|
|
296
|
+
opts.environment["FAKE_CLAUDE_FAILS"] = "1"
|
|
297
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="fails")
|
|
298
|
+
result = await wait_for(lambda: runtime.inspect(value["execution_id"])["result"])
|
|
299
|
+
assert result["status"] == "failed" and "Invalid API key" not in result["error"]
|
|
300
|
+
# The last 20 lines, each cut to 500 characters, apart from the error.
|
|
301
|
+
lines = result["native_stderr"]
|
|
302
|
+
assert len(lines) == 20 and lines[-1] == "Invalid API key - Please run /login"
|
|
303
|
+
assert all(len(line) <= 500 for line in lines) and lines[0].startswith("line 11 ")
|
|
304
|
+
assert runtime.store.receipt(value["session_id"], "m1")["status"] == "rejected"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def test_stderr_from_before_a_result_is_not_reported_with_a_later_failure(tmp_path, native):
|
|
308
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
309
|
+
opts = options(tmp_path, native)
|
|
310
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="late")
|
|
311
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
312
|
+
await wait_for(lambda: runtime.store.receipt(sid, "m1")["status"] == "delivered")
|
|
313
|
+
await runtime.submit(sid, "wrong-session", message_id="m2", options=opts, resume=False)
|
|
314
|
+
result = await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
315
|
+
assert result["status"] == "failed" and "different session ID" in result["error"]
|
|
316
|
+
assert "native_stderr" not in result # the warning at the start came before the first result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def test_a_launch_id_from_an_older_version_can_be_repeated(tmp_path, native):
|
|
320
|
+
"""0.2.1 stored no effort and the link target of the executable; a repeat of that launch
|
|
321
|
+
by this version must find the same execution."""
|
|
322
|
+
link = tmp_path / "shim"
|
|
323
|
+
link.symlink_to(native)
|
|
324
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
325
|
+
opts = LaunchOptions(
|
|
326
|
+
cwd=tmp_path,
|
|
327
|
+
claude=link,
|
|
328
|
+
idle_timeout_seconds=0,
|
|
329
|
+
environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
|
|
330
|
+
)
|
|
331
|
+
assert "effort" not in opts.configuration()
|
|
332
|
+
first = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
|
|
333
|
+
eid = first["execution_id"]
|
|
334
|
+
try:
|
|
335
|
+
stored = dict(runtime.store.execution(eid)["configuration"], claude=str(native.resolve()))
|
|
336
|
+
runtime.store.update(eid, configuration=stored)
|
|
337
|
+
again = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
|
|
338
|
+
assert again["execution_id"] == eid
|
|
339
|
+
with pytest.raises(ValueError, match="configuration"):
|
|
340
|
+
changed = LaunchOptions(
|
|
341
|
+
cwd=tmp_path, claude=link, effort="high", idle_timeout_seconds=0
|
|
342
|
+
)
|
|
343
|
+
await runtime.start("hello", message_id="m1", options=changed, launch_id="old")
|
|
344
|
+
finally:
|
|
345
|
+
await runtime.stop(eid)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 5
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.14'",
|
|
6
6
|
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
|
|
7
|
-
"
|
|
7
|
+
"python_full_version < '3.12' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
|
|
8
8
|
]
|
|
9
9
|
|
|
10
10
|
[[package]]
|
|
11
11
|
name = "agent-runtime-py"
|
|
12
|
-
version = "0.2.
|
|
12
|
+
version = "0.2.2"
|
|
13
13
|
source = { editable = "." }
|
|
14
14
|
dependencies = [
|
|
15
15
|
{ name = "filelock" },
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.2.0
|
|
4
|
-
|
|
5
|
-
- Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
|
|
6
|
-
- Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
|
|
7
|
-
- Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
|
|
8
|
-
- Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
"""Exercise the real SDK and detached processes against a deterministic native boundary."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
from agent_runtime import ClaudeRuntime, LaunchOptions
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pytest.fixture
|
|
15
|
-
def native(tmp_path: Path) -> Path:
|
|
16
|
-
cli = tmp_path / "claude"
|
|
17
|
-
cli.write_text(
|
|
18
|
-
f"#!{sys.executable}\n"
|
|
19
|
-
+ r"""
|
|
20
|
-
import json, os, sys
|
|
21
|
-
if "--version" in sys.argv:
|
|
22
|
-
print("2.1.281 (Claude Code)")
|
|
23
|
-
raise SystemExit(0)
|
|
24
|
-
def emit(value):
|
|
25
|
-
print(json.dumps(value), flush=True)
|
|
26
|
-
session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
|
|
27
|
-
def result(text):
|
|
28
|
-
emit({"type":"result", "subtype":"success", "is_error":False,
|
|
29
|
-
"duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
|
|
30
|
-
"result":text,"origin":{"kind":"human"},"usage":{}})
|
|
31
|
-
pending = None
|
|
32
|
-
for line in sys.stdin:
|
|
33
|
-
message = json.loads(line)
|
|
34
|
-
if message["type"] == "control_request":
|
|
35
|
-
emit({"type":"control_response","response":{"subtype":"success",
|
|
36
|
-
"request_id":message["request_id"],"response":{}}})
|
|
37
|
-
elif message["type"] == "user":
|
|
38
|
-
text = message["message"]["content"]
|
|
39
|
-
with open(os.environ["FAKE_CLAUDE_LOG"], "a") as log:
|
|
40
|
-
log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
|
|
41
|
-
if text == "crash-before-echo":
|
|
42
|
-
raise SystemExit(23)
|
|
43
|
-
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
44
|
-
if text == "permission":
|
|
45
|
-
pending = text
|
|
46
|
-
emit({"type":"control_request","request_id":"permission-1",
|
|
47
|
-
"request":{"subtype":"can_use_tool","tool_name":"Bash",
|
|
48
|
-
"input":{"command":"touch example"},"tool_use_id":"tool-1"}})
|
|
49
|
-
elif text != "hold":
|
|
50
|
-
result("echo: " + text)
|
|
51
|
-
elif message["type"] == "control_response" and pending:
|
|
52
|
-
result(json.dumps(message["response"]["response"]))
|
|
53
|
-
pending = None
|
|
54
|
-
"""
|
|
55
|
-
)
|
|
56
|
-
cli.chmod(0o700)
|
|
57
|
-
return cli
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
async def wait_for(predicate):
|
|
61
|
-
for _ in range(200):
|
|
62
|
-
if value := predicate():
|
|
63
|
-
return value
|
|
64
|
-
await asyncio.sleep(0.05)
|
|
65
|
-
raise AssertionError("condition did not become true")
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def options(tmp_path: Path, native: Path) -> LaunchOptions:
|
|
69
|
-
return LaunchOptions(
|
|
70
|
-
cwd=tmp_path,
|
|
71
|
-
claude=native,
|
|
72
|
-
idle_timeout_seconds=0,
|
|
73
|
-
environment={
|
|
74
|
-
"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
|
|
75
|
-
"PRIVATE_TEST_SECRET": "do-not-persist-this",
|
|
76
|
-
},
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
async def test_reconnect_duplicate_admission_and_explicit_resume(tmp_path, native):
|
|
81
|
-
root = tmp_path / "runtime"
|
|
82
|
-
runtime = ClaudeRuntime(root)
|
|
83
|
-
opts = options(tmp_path, native)
|
|
84
|
-
first = await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1")
|
|
85
|
-
sid, eid = first["session_id"], first["execution_id"]
|
|
86
|
-
try:
|
|
87
|
-
await wait_for(
|
|
88
|
-
lambda: any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
|
|
89
|
-
)
|
|
90
|
-
# A new client is the entire application-service restart contract.
|
|
91
|
-
runtime = ClaudeRuntime(root)
|
|
92
|
-
assert runtime.inspect(eid)["result"] is None
|
|
93
|
-
assert runtime.store.receipt(sid, "m1")["status"] == "delivered"
|
|
94
|
-
assert (await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1"))[
|
|
95
|
-
"execution_id"
|
|
96
|
-
] == eid
|
|
97
|
-
with pytest.raises(ValueError, match="different"):
|
|
98
|
-
await runtime.start("changed", message_id="m1", options=opts, launch_id="launch1")
|
|
99
|
-
await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
100
|
-
await wait_for(lambda: runtime.store.receipt(sid, "m2")["status"] == "delivered")
|
|
101
|
-
receipt = await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
102
|
-
assert receipt["status"] == "delivered"
|
|
103
|
-
with pytest.raises(ValueError):
|
|
104
|
-
await runtime.submit(sid, "different", message_id="m2", options=opts, resume=False)
|
|
105
|
-
assert "do-not-persist-this" not in runtime.store.path.read_bytes().decode(errors="ignore")
|
|
106
|
-
assert os.stat(root).st_mode & 0o777 == 0o700
|
|
107
|
-
assert os.stat(runtime.store.path).st_mode & 0o777 == 0o600
|
|
108
|
-
assert (await runtime.stop(eid))["result"]["status"] == "stopped"
|
|
109
|
-
rejected = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=False)
|
|
110
|
-
assert rejected["status"] == "rejected"
|
|
111
|
-
resumed = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=True)
|
|
112
|
-
eid = resumed["execution_id"]
|
|
113
|
-
assert eid != first["execution_id"]
|
|
114
|
-
await wait_for(lambda: runtime.store.receipt(sid, "m3")["status"] == "delivered")
|
|
115
|
-
recorded = [
|
|
116
|
-
json.loads(line) for line in (tmp_path / "inputs.jsonl").read_text().splitlines()
|
|
117
|
-
]
|
|
118
|
-
assert [r["text"] for r in recorded] == ["hello", "next", "later"]
|
|
119
|
-
assert {r["session"] for r in recorded} == {sid}
|
|
120
|
-
finally:
|
|
121
|
-
await runtime.stop(eid)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
async def test_lost_native_ack_is_uncertain_and_never_replayed(tmp_path, native):
|
|
125
|
-
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
126
|
-
opts = options(tmp_path, native)
|
|
127
|
-
value = await runtime.start(
|
|
128
|
-
"crash-before-echo", message_id="m1", options=opts, launch_id="crash"
|
|
129
|
-
)
|
|
130
|
-
sid, eid = value["session_id"], value["execution_id"]
|
|
131
|
-
try:
|
|
132
|
-
await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
133
|
-
assert runtime.store.receipt(sid, "m1")["status"] == "uncertain"
|
|
134
|
-
saved = await runtime.submit(
|
|
135
|
-
sid, "crash-before-echo", message_id="m1", options=opts, resume=True
|
|
136
|
-
)
|
|
137
|
-
assert saved["status"] == "uncertain"
|
|
138
|
-
assert len((tmp_path / "inputs.jsonl").read_text().splitlines()) == 1
|
|
139
|
-
finally:
|
|
140
|
-
await runtime.stop(eid)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
|
|
144
|
-
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
145
|
-
value = await runtime.start(
|
|
146
|
-
"permission", message_id="m1", options=options(tmp_path, native), launch_id="permission"
|
|
147
|
-
)
|
|
148
|
-
sid, eid = value["session_id"], value["execution_id"]
|
|
149
|
-
try:
|
|
150
|
-
requests = await wait_for(lambda: runtime.store.interactions(eid))
|
|
151
|
-
assert requests[0]["params"]["tool"] == "Bash"
|
|
152
|
-
assert not any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
|
|
153
|
-
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
154
|
-
runtime.store.answer(eid, requests[0]["id"], {"decision": "deny"})
|
|
155
|
-
settled = await wait_for(
|
|
156
|
-
lambda: [
|
|
157
|
-
e["event"]
|
|
158
|
-
for e in runtime.store.events(sid)
|
|
159
|
-
if e["event"]["type"] == "work.settled"
|
|
160
|
-
]
|
|
161
|
-
)
|
|
162
|
-
assert json.loads(settled[0]["message"])["behavior"] == "deny"
|
|
163
|
-
with pytest.raises(ValueError):
|
|
164
|
-
runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
|
|
165
|
-
finally:
|
|
166
|
-
await runtime.stop(eid)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|