genesis-cli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/PKG-INFO +2 -2
  2. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/pyproject.toml +2 -2
  3. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/claude_driver.py +134 -0
  4. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/client.py +10 -1
  5. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/loop.py +48 -1
  6. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/runner_cmd.py +3 -3
  7. genesis_cli-0.3.0/tests/test_runner_completion.py +167 -0
  8. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/.gitignore +0 -0
  9. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/LICENSE +0 -0
  10. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/README.md +0 -0
  11. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/action/README.md +0 -0
  12. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/action/action.yml +0 -0
  13. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/__init__.py +0 -0
  14. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/adopt.py +0 -0
  15. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/agents_md.py +0 -0
  16. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/baseline.py +0 -0
  17. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/gates.py +0 -0
  18. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/init.py +0 -0
  19. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/main.py +0 -0
  20. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/report.py +0 -0
  21. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/review.py +0 -0
  22. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/__init__.py +0 -0
  23. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/config.py +0 -0
  24. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/workspace.py +0 -0
  25. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/sarif.py +0 -0
  26. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_adopt.py +0 -0
  27. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_baseline.py +0 -0
  28. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_gates_cmd.py +0 -0
  29. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_init.py +0 -0
  30. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_review.py +0 -0
  31. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_config.py +0 -0
  32. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_driver.py +0 -0
  33. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_workspace.py +0 -0
  34. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_sarif.py +0 -0
  35. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_schemas.py +0 -0
  36. {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: genesis-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed.
5
5
  Project-URL: Homepage, https://github.com/Inceptium-ai/genesis-framework
6
6
  Project-URL: Repository, https://github.com/Inceptium-ai/genesis-framework
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Software Development :: Quality Assurance
21
21
  Classifier: Topic :: Software Development :: Testing
22
22
  Requires-Python: >=3.12
23
- Requires-Dist: genesis-gates>=0.2.0
23
+ Requires-Dist: genesis-gates>=0.3.0
24
24
  Requires-Dist: httpx>=0.27
25
25
  Requires-Dist: pyyaml>=6.0
26
26
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "genesis-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  # Must stay a REAL version requirement: `[tool.uv.sources]` below is a
31
31
  # dev-only workspace override and is not written into published metadata,
32
32
  # so this is what `pip install genesis-cli` resolves against on PyPI.
33
- "genesis-gates>=0.2.0",
33
+ "genesis-gates>=0.3.0",
34
34
  "pyyaml>=6.0",
35
35
  # `genesis runner` talks to a Genesis server over HTTP.
36
36
  "httpx>=0.27",
@@ -6,6 +6,12 @@ Runs `claude -p <prompt> --output-format stream-json` as an argv subprocess
6
6
  (never a shell) with cwd = the task workspace. The child env is a pass-through
7
7
  of the login environment with ANTHROPIC_API_KEY explicitly UNSET so the CLI
8
8
  uses the user's subscription auth and never silently bills an API key.
9
+
10
+ Planning completions (0.3.0) use the same CLI in ONE-SHOT print mode —
11
+ `claude -p <prompt> --output-format json --system-prompt <system> --tools ""
12
+ --no-session-persistence` — run in a private empty directory with all tools
13
+ disabled: a pure text-in/text-out LLM call, parsed from the single result
14
+ JSON object (any non-JSON wrapper noise stripped).
9
15
  """
10
16
 
11
17
  from __future__ import annotations
@@ -15,6 +21,7 @@ import json
15
21
  import os
16
22
  import shutil
17
23
  import subprocess
24
+ import tempfile
18
25
  import time
19
26
  from collections.abc import Callable
20
27
  from pathlib import Path
@@ -60,6 +67,56 @@ def _assistant_text(evt: dict) -> str:
60
67
  )
61
68
 
62
69
 
70
+ def _flatten_messages(messages: list[dict]) -> str:
71
+ """One-shot print mode takes a single prompt, so a conversation (e.g. the
72
+ server's schema-repair retry: user, assistant, user) is flattened with
73
+ role tags. A single user message passes through untagged."""
74
+ if len(messages) == 1 and messages[0].get("role") == "user":
75
+ return str(messages[0].get("content") or "")
76
+ parts = []
77
+ for m in messages:
78
+ role = str(m.get("role") or "user").upper()
79
+ parts.append(f"[{role}]\n{m.get('content') or ''}")
80
+ return "\n\n".join(parts)
81
+
82
+
83
+ def _final_json_object(stdout: str) -> dict | None:
84
+ """The result object from `--output-format json` stdout, tolerating any
85
+ non-JSON wrapper lines (update notices, MOTD) before or after it."""
86
+ text = stdout.strip()
87
+ try:
88
+ obj = json.loads(text)
89
+ if isinstance(obj, dict):
90
+ return obj
91
+ except json.JSONDecodeError:
92
+ pass
93
+ # Line-wise, last parseable object wins (the result is printed last).
94
+ for line in reversed(text.splitlines()):
95
+ line = line.strip()
96
+ if not (line.startswith("{") and line.endswith("}")):
97
+ continue
98
+ try:
99
+ obj = json.loads(line)
100
+ if isinstance(obj, dict):
101
+ return obj
102
+ except json.JSONDecodeError:
103
+ continue
104
+ # Last resort: scan for embedded objects; the last decodable one wins.
105
+ dec = json.JSONDecoder()
106
+ best: dict | None = None
107
+ i = text.find("{")
108
+ while i != -1:
109
+ try:
110
+ obj, end = dec.raw_decode(text, i)
111
+ except json.JSONDecodeError:
112
+ i = text.find("{", i + 1)
113
+ continue
114
+ if isinstance(obj, dict):
115
+ best = obj
116
+ i = text.find("{", end)
117
+ return best
118
+
119
+
63
120
  def check_cli() -> tuple[bool, str]:
64
121
  """Preflight: is a working `claude` CLI on PATH?"""
65
122
  if not shutil.which("claude"):
@@ -183,3 +240,80 @@ class ClaudeDriver:
183
240
  usage["duration_s"] = time.monotonic() - started
184
241
  return {"status": status, "session_id": session_id,
185
242
  "usage": usage, "error": error}
243
+
244
+ async def run_completion(self, *, system: str | None, messages: list[dict],
245
+ max_tokens: int = 4096,
246
+ timeout_s: float = 150.0) -> dict:
247
+ """One planning completion (T-047): run `claude` in one-shot print
248
+ mode and return {status, text, usage, error}. Verified invocation:
249
+ `claude -p <prompt> --output-format json --system-prompt <system>
250
+ --tools "" --no-session-persistence` — a single result JSON object on
251
+ stdout whose `result` field is the model's text. Tools are disabled
252
+ and cwd is a throwaway empty directory: a pure LLM call, nothing on
253
+ this machine is read or written. The CLI exposes no max-tokens flag;
254
+ the server bounds output through the prompt contract instead.
255
+ """
256
+ del max_tokens # accepted for wire-payload symmetry; see docstring
257
+ started = time.monotonic()
258
+ prompt = _flatten_messages(messages)
259
+ argv = [
260
+ "claude", "-p", prompt,
261
+ "--output-format", "json",
262
+ "--tools", "",
263
+ "--no-session-persistence",
264
+ ]
265
+ if system:
266
+ argv += ["--system-prompt", system]
267
+ if self.model:
268
+ argv += ["--model", self.model]
269
+
270
+ usage: dict = {"input_tokens": 0, "output_tokens": 0, "duration_s": None}
271
+ with tempfile.TemporaryDirectory(prefix="genesis-completion-") as empty:
272
+ try:
273
+ proc = await asyncio.create_subprocess_exec(
274
+ *argv,
275
+ cwd=empty,
276
+ env=self._child_env(),
277
+ stdout=asyncio.subprocess.PIPE,
278
+ stderr=asyncio.subprocess.PIPE,
279
+ limit=_STREAM_LIMIT,
280
+ )
281
+ except FileNotFoundError:
282
+ return {"status": "failed", "text": None, "usage": usage,
283
+ "error": "claude CLI not found"}
284
+ self._proc = proc
285
+ try:
286
+ out_b, err_b = await asyncio.wait_for(proc.communicate(),
287
+ timeout=timeout_s)
288
+ except TimeoutError:
289
+ proc.kill()
290
+ await proc.wait()
291
+ return {"status": "failed", "text": None, "usage": usage,
292
+ "error": f"completion timed out after {timeout_s:.0f}s"}
293
+ except asyncio.CancelledError:
294
+ proc.kill()
295
+ raise
296
+ finally:
297
+ self._proc = None
298
+
299
+ usage["duration_s"] = time.monotonic() - started
300
+ if self.killed:
301
+ return {"status": "interrupted", "text": None, "usage": usage,
302
+ "error": "completion aborted on the runner"}
303
+
304
+ evt = _final_json_object(out_b.decode("utf-8", "replace"))
305
+ if evt is None:
306
+ err = err_b.decode("utf-8", "replace")[:500]
307
+ return {"status": "failed", "text": None, "usage": usage,
308
+ "error": err or f"claude exited {proc.returncode} "
309
+ "with no result JSON"}
310
+ u = evt.get("usage") or {}
311
+ usage["input_tokens"] = int(u.get("input_tokens") or 0)
312
+ usage["output_tokens"] = int(u.get("output_tokens") or 0)
313
+ text = evt.get("result")
314
+ if evt.get("is_error") or evt.get("subtype") != "success" \
315
+ or not isinstance(text, str):
316
+ return {"status": "failed", "text": None, "usage": usage,
317
+ "error": str(evt.get("result") or "cli error")[:500]}
318
+ return {"status": "completed", "text": text, "usage": usage,
319
+ "error": None}
@@ -15,6 +15,12 @@ import httpx
15
15
  _CLAIM_TIMEOUT = httpx.Timeout(10.0, read=60.0)
16
16
  _IO_TIMEOUT = httpx.Timeout(10.0, read=300.0)
17
17
 
18
+ # What this client can serve, advertised on every claim (0.3.0+). 'completion'
19
+ # = one-shot planning LLM calls through the local `claude` CLI, no workspace.
20
+ # Additive wire change: an 0.2.0 server has no body on /claim and ignores it;
21
+ # an 0.3.0 server hands completion items only to claims that advertise it.
22
+ CAPABILITIES = ("completion",)
23
+
18
24
 
19
25
  class RunnerAuthError(RuntimeError):
20
26
  """The server rejected the runner token (bad, rotated, or revoked)."""
@@ -49,7 +55,10 @@ class RunnerClient:
49
55
 
50
56
  async def claim(self) -> dict | None:
51
57
  """One long-poll. None = no work this window (poll again)."""
52
- resp = await self._client.post("/api/runner/claim", timeout=_CLAIM_TIMEOUT)
58
+ resp = await self._client.post(
59
+ "/api/runner/claim", json={"capabilities": list(CAPABILITIES)},
60
+ timeout=_CLAIM_TIMEOUT,
61
+ )
53
62
  self._check_auth(resp)
54
63
  if resp.status_code == 204:
55
64
  return None
@@ -101,6 +101,15 @@ def status() -> int:
101
101
  _say(f"Auth : unreachable ({exc})")
102
102
  return 1
103
103
  _say(f"Auth : {'ok' if auth_ok else 'REJECTED — reconnect with a fresh token'}")
104
+ # Planning routing: the capability is advertised on every claim; there is
105
+ # no cheap server query for the ack, so report the client side honestly.
106
+ from .client import CAPABILITIES
107
+
108
+ if "completion" in CAPABILITIES:
109
+ _say("Planning: this runner advertises 'completion' — while "
110
+ "`genesis runner start` is running, the server routes your "
111
+ "planning calls here (needs a 0.3.0+ server; older servers "
112
+ "ignore it and use builds only).")
104
113
  return 0 if (ok and auth_ok) else 1
105
114
 
106
115
 
@@ -165,13 +174,51 @@ async def _start(cfg: dict) -> int:
165
174
  continue
166
175
  if item is None:
167
176
  continue
168
- await _run_item(client, item, stop)
177
+ if item.get("kind") == "completion":
178
+ await _run_completion_item(client, item, stop)
179
+ else:
180
+ await _run_item(client, item, stop)
169
181
  finally:
170
182
  await client.aclose()
171
183
  _say("Runner stopped.")
172
184
  return 0
173
185
 
174
186
 
187
+ async def _run_completion_item(client: RunnerClient, item: dict,
188
+ stop: _Stop) -> None:
189
+ """One planning completion (0.3.0): no workspace in either direction —
190
+ run the local `claude` in one-shot print mode and post the text back."""
191
+ item_id = str(item.get("item_id"))
192
+ task = item.get("task") or {}
193
+ _say(f"▶ planning call (item {item_id[:12]}…) — running claude one-shot")
194
+ driver = ClaudeDriver()
195
+ stop.driver = driver
196
+ try:
197
+ result = await driver.run_completion(
198
+ system=str(task.get("system") or "") or None,
199
+ messages=list(task.get("messages") or []),
200
+ max_tokens=int(task.get("max_tokens") or 4096),
201
+ )
202
+ except Exception as exc: # noqa: BLE001 — report, never crash the loop
203
+ result = {"status": "failed", "text": None, "usage": {},
204
+ "error": f"runner error: {exc}"[:500]}
205
+ finally:
206
+ stop.driver = None
207
+ payload = {
208
+ "status": result.get("status", "failed"),
209
+ "text": result.get("text"),
210
+ "session_id": None,
211
+ "error": result.get("error"),
212
+ "usage": result.get("usage") or {},
213
+ "deleted": [],
214
+ }
215
+ try:
216
+ await client.post_result(item_id, payload)
217
+ _say(f"✓ planning call: {payload['status']}")
218
+ except (httpx.HTTPError, RunnerAuthError) as exc:
219
+ _say(f"✗ planning call: result upload failed ({exc.__class__.__name__})")
220
+
221
+
175
222
  async def _run_item(client: RunnerClient, item: dict, stop: _Stop) -> None:
176
223
  item_id = str(item.get("item_id"))
177
224
  task = item.get("task") or {}
@@ -27,9 +27,9 @@ def _cmd_status(args: argparse.Namespace) -> int:
27
27
  def add_runner_parser(sub: argparse._SubParsersAction) -> None:
28
28
  runner = sub.add_parser(
29
29
  "runner",
30
- help="execute your Genesis builds' coding-agent tasks on THIS machine "
31
- "with your local claude CLI (subscription) — the server keeps "
32
- "orchestration, validation, gates, and billing",
30
+ help="execute your Genesis builds' coding-agent tasks AND planning "
31
+ "calls on THIS machine with your local claude CLI (subscription) "
32
+ "— the server keeps orchestration, validation, gates, and billing",
33
33
  )
34
34
  rsub = runner.add_subparsers(dest="runner_command", required=True)
35
35
 
@@ -0,0 +1,167 @@
1
+ """Planning completions on the runner (0.3.0): one-shot `claude -p` driving
2
+ against a fake CLI on PATH, capability advertisement on claim, and the loop's
3
+ completion-item round trip. Offline only — nothing here talks to a network.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import stat
11
+ import textwrap
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ import pytest
16
+ from genesis_cli.runner.claude_driver import ClaudeDriver, _final_json_object
17
+ from genesis_cli.runner.client import CAPABILITIES, RunnerClient
18
+
19
+
20
+ @pytest.fixture
21
+ def fake_claude(tmp_path: Path, monkeypatch):
22
+ """Install a stub `claude` on PATH. One-shot print mode records its argv
23
+ and env to $T047_RECORD (cwd is a throwaway dir the driver deletes)."""
24
+
25
+ def install(body: str) -> Path:
26
+ bin_dir = tmp_path / "bin"
27
+ bin_dir.mkdir(exist_ok=True)
28
+ script = bin_dir / "claude"
29
+ script.write_text("#!/usr/bin/env python3\n" + textwrap.dedent(body))
30
+ script.chmod(script.stat().st_mode | stat.S_IEXEC)
31
+ monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}")
32
+ record = tmp_path / "record.json"
33
+ monkeypatch.setenv("T047_RECORD", str(record))
34
+ return record
35
+
36
+ return install
37
+
38
+
39
+ ONESHOT_BODY = """
40
+ import json, os, sys
41
+ args = sys.argv[1:]
42
+ open(os.environ["T047_RECORD"], "w").write(json.dumps(
43
+ {"argv": args, "has_api_key": "ANTHROPIC_API_KEY" in os.environ,
44
+ "cwd": os.getcwd()}))
45
+ # Wrapper noise before the result object must not break parsing.
46
+ print("Update available! run claude update")
47
+ print(json.dumps({"type": "result", "subtype": "success", "is_error": False,
48
+ "result": '{"name": "zed"}', "session_id": "sess-1",
49
+ "total_cost_usd": 0,
50
+ "usage": {"input_tokens": 42, "output_tokens": 7}}))
51
+ """
52
+
53
+
54
+ async def test_run_completion_oneshot_flags_and_parse(fake_claude, monkeypatch):
55
+ record = fake_claude(ONESHOT_BODY)
56
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-should-not-leak")
57
+ out = await ClaudeDriver().run_completion(
58
+ system="You are a planner.",
59
+ messages=[{"role": "user", "content": "make zed"}])
60
+ assert out["status"] == "completed"
61
+ assert out["text"] == '{"name": "zed"}'
62
+ assert out["usage"]["input_tokens"] == 42
63
+ assert out["usage"]["output_tokens"] == 7
64
+ assert out["usage"]["duration_s"] is not None
65
+
66
+ rec = json.loads(record.read_text())
67
+ argv = rec["argv"]
68
+ # The verified one-shot invocation, exactly: print mode, single JSON
69
+ # result, no tools, no persisted session, explicit system prompt.
70
+ assert argv[:2] == ["-p", "make zed"]
71
+ assert argv[2:4] == ["--output-format", "json"]
72
+ assert argv[4:6] == ["--tools", ""]
73
+ assert argv[6] == "--no-session-persistence"
74
+ assert argv[7:9] == ["--system-prompt", "You are a planner."]
75
+ # Subscription hygiene + isolation: no API key, throwaway empty cwd.
76
+ assert rec["has_api_key"] is False
77
+ assert "genesis-completion-" in rec["cwd"]
78
+ assert not Path(rec["cwd"]).exists() # cleaned up after the call
79
+
80
+
81
+ async def test_run_completion_flattens_multi_turn(fake_claude):
82
+ record = fake_claude(ONESHOT_BODY)
83
+ await ClaudeDriver().run_completion(
84
+ system=None,
85
+ messages=[{"role": "user", "content": "go"},
86
+ {"role": "assistant", "content": "{bad"},
87
+ {"role": "user", "content": "fix it"}])
88
+ argv = json.loads(record.read_text())["argv"]
89
+ prompt = argv[1]
90
+ assert "[USER]\ngo" in prompt and "[ASSISTANT]\n{bad" in prompt
91
+ assert "--system-prompt" not in argv
92
+
93
+
94
+ async def test_run_completion_error_result(fake_claude):
95
+ fake_claude("""
96
+ import json
97
+ print(json.dumps({"type": "result", "subtype": "success", "is_error": True,
98
+ "result": "Not logged in", "usage": {}}))
99
+ """)
100
+ out = await ClaudeDriver().run_completion(
101
+ system=None, messages=[{"role": "user", "content": "go"}])
102
+ assert out["status"] == "failed"
103
+ assert "Not logged in" in out["error"]
104
+
105
+
106
+ async def test_run_completion_no_json_is_failed(fake_claude):
107
+ fake_claude("""
108
+ import sys
109
+ print("something went sideways")
110
+ sys.exit(3)
111
+ """)
112
+ out = await ClaudeDriver().run_completion(
113
+ system=None, messages=[{"role": "user", "content": "go"}])
114
+ assert out["status"] == "failed" and out["text"] is None
115
+
116
+
117
+ def test_final_json_object_strips_wrappers():
118
+ noise = 'MOTD banner\n{"type": "result", "result": "ok"}\ntrailing text'
119
+ assert _final_json_object(noise)["result"] == "ok"
120
+ assert _final_json_object("no json here") is None
121
+ # Embedded (non-line-aligned) object still found; the last one wins.
122
+ assert _final_json_object('x {"a": 1} y {"b": 2} z') == {"b": 2}
123
+
124
+
125
+ async def test_claim_advertises_capabilities(monkeypatch):
126
+ assert "completion" in CAPABILITIES
127
+ client = RunnerClient("http://test", "grt_x")
128
+ captured: dict = {}
129
+
130
+ async def fake_post(url, **kw):
131
+ captured["url"] = url
132
+ captured["json"] = kw.get("json")
133
+ return httpx.Response(204, request=httpx.Request("POST", f"http://test{url}"))
134
+
135
+ monkeypatch.setattr(client._client, "post", fake_post)
136
+ try:
137
+ assert await client.claim() is None
138
+ finally:
139
+ await client.aclose()
140
+ assert captured["url"] == "/api/runner/claim"
141
+ assert captured["json"] == {"capabilities": ["completion"]}
142
+
143
+
144
+ async def test_loop_runs_completion_item_and_posts_text(fake_claude):
145
+ from genesis_cli.runner.loop import _run_completion_item, _Stop
146
+
147
+ fake_claude(ONESHOT_BODY)
148
+
149
+ class _FakeClient:
150
+ def __init__(self):
151
+ self.results: list[tuple[str, dict]] = []
152
+
153
+ async def post_result(self, item_id, payload, tar_path=None):
154
+ assert tar_path is None # completions never upload an archive
155
+ self.results.append((item_id, payload))
156
+
157
+ client = _FakeClient()
158
+ await _run_completion_item(client, {
159
+ "item_id": "item-1", "kind": "completion",
160
+ "task": {"system": "sys", "max_tokens": 128,
161
+ "messages": [{"role": "user", "content": "make zed"}]},
162
+ }, _Stop())
163
+ (item_id, payload), = client.results
164
+ assert item_id == "item-1"
165
+ assert payload["status"] == "completed"
166
+ assert payload["text"] == '{"name": "zed"}'
167
+ assert payload["deleted"] == [] and payload["session_id"] is None
File without changes
File without changes
File without changes