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.
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/PKG-INFO +2 -2
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/pyproject.toml +2 -2
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/claude_driver.py +134 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/client.py +10 -1
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/loop.py +48 -1
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/runner_cmd.py +3 -3
- genesis_cli-0.3.0/tests/test_runner_completion.py +167 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/.gitignore +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/LICENSE +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/README.md +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/action/README.md +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/action/action.yml +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/__init__.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/adopt.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/agents_md.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/baseline.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/gates.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/init.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/main.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/report.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/review.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/__init__.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/config.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/runner/workspace.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/src/genesis_cli/sarif.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_adopt.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_baseline.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_gates_cmd.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_init.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_review.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_config.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_driver.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_runner_workspace.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_sarif.py +0 -0
- {genesis_cli-0.2.0 → genesis_cli-0.3.0}/tests/test_schemas.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
31
|
-
"with your local claude CLI (subscription)
|
|
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
|
|
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
|
|
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
|