feat(worker): drive Hermes gateway API server instead of claude -p

Claude Code could not complete AI fixes / conflict resolution against this
host's provider setup: it hung 900s spawning an MCP server, then failed with
'body is JSON but not a Message' (Anthropic-Messages transport mismatch), then
exited 1 with empty stderr. The Hermes gateway already runs continuously with
the working 9router provider config and a full toolset, so drive it directly:

- POST http://127.0.0.1:8642/v1/chat/completions (OpenAI-compatible API server)
- bearer auth from API_SERVER_KEY (env or ~/.hermes/.env), overridable via
  API_SERVER_URL
- model_options.max_turns caps a runaway run; 900s timeout for sync, 600s for
  PR fixes
- every failure maps to an [INFRA] string so the existing skip-once logic works
- the agent commits locally; the WORKER pushes (agents must never push)

run_ai_fix no longer shells out to claude; it calls the API server, then pushes
the agent's commit itself and reports push failures explicitly. Sync call sites
keep their contract via _run_claude_sync -> _run_hermes_sync alias, with labels
renamed hermes_sync_conflicts / hermes_sync_quality.

Tests: 59/59 (8 new assertions exercise a real local HTTP round-trip: path,
bearer auth, OpenAI message shape, max_turns cap, HTTP-error/missing-key/
unreachable -> [INFRA]).
This commit is contained in:
asepharyana
2026-09-21 18:24:47 +07:00
parent 0653f6c508
commit 99d47c8ff8
3 changed files with 237 additions and 123 deletions
+12 -9
View File
@@ -8,8 +8,10 @@ repos where the PR-Agent GitHub App is installed and drives the full lifecycle:
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
of waiting on them forever
3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to
`AI_FIX_MAX_TURNS` turns, then pushes the fix
3. **AI auto-fix** → runs the Hermes agent via the local gateway API server
(`POST /v1/chat/completions` on `127.0.0.1:8642`, `API_SERVER_KEY`) on the PR
head for up to `AI_FIX_MAX_TURNS` turns, commits locally, then the worker
pushes the fix
5. **Safety analysis** → parses the review body for security/major-issue
blockers; score must be ≥ 6/10
6. **CI gate** → waits for the required check to pass (closes stale dependabot
@@ -23,11 +25,12 @@ installation whose GitHub metadata says `fork: true`: new upstream (parent)
commits are **merged** (never rebased) into the fork's default branch, gated by
a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block).
- **Conflicted merge** → Claude Code resolves it (merge-reconciler rules: merge
hunks by hand, never wholesale `--ours/--theirs`, run the repo's own
typecheck/tests before committing). Claude never pushes — the harness does.
- **Clean merge** → one Claude Code quality pass over the merged files,
committed as `fix: auto-fix code quality [skip ci]`.
- **Conflicted merge** → the Hermes agent (via the gateway API server) resolves
it (merge-reconciler rules: merge hunks by hand, never wholesale
`--ours/--theirs`, run the repo's own typecheck/tests before committing). The
agent never pushes — the harness does.
- **Clean merge** → one Hermes quality pass over the merged files, committed as
`fix: auto-fix code quality [skip ci]`.
- **Protected default branch** → detect from the push result (GH006 /
required-status-check) and fall back to opening an `upstream-sync-*` PR that
the normal pipeline (review → AI fix → CI → approve → merge) finishes.
@@ -47,8 +50,8 @@ python3 scripts/pr-queue-worker.py --sync-status
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry # stops before push
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
```
Tests: `python3 scripts/test_pr_queue_sync.py` (46 assertions; no network —
gh_api/git/Claude/push are monkeypatched).
Tests: `python3 scripts/test_pr_queue_sync.py` (51 assertions; no network —
gh_api/git/Hermes API/push are monkeypatched).
## Deployment
+143 -109
View File
@@ -74,6 +74,10 @@ AI_FIX_ENABLED = True
CLAUDE_BIN = shutil.which("claude") or "/usr/local/bin/claude"
AI_FIX_MAX_TURNS = 100
AI_FIX_TIMEOUT = 600 # seconds per PR
# Hermes gateway API server (OpenAI-compatible). The worker drives the running
# gateway instead of spawning a CLI: the gateway already holds the provider
# (9router) config and a full toolset (terminal/file/web).
API_SERVER_URL = os.environ.get("API_SERVER_URL", "") or "http://127.0.0.1:8642/v1"
TRACKING_DIR = Path("/tmp/pr-queue-pids")
AI_FIX_COST_CAP = 0.50 # max budget USD per fix session
@@ -875,7 +879,8 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
return False, "no changed files to fix"
# Build Claude Code prompt
# Build AI prompt (Hermes API-server agent; the worker pushes, agent only
# resolves + commits locally in the cloned workdir)
file_list = "\n".join(" - " + f for f in changed_files[:30])
if len(changed_files) > 30:
file_list += "\n ... and " + str(len(changed_files) - 30) + " more"
@@ -884,6 +889,7 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
'You are on the PR #' + str(pr_num) + ' branch of ' + str(repo_full) + ': "' + str(title) + '"\n\n'
'Files changed in this PR:\n'
+ file_list + '\n\n'
'Your working directory is the git worktree for this PR branch.\n'
'Your task:\n'
'1. FIRST, try to merge the base branch to resolve any stale conflicts:\n'
' git fetch origin ' + str(base_ref) + '\n'
@@ -895,73 +901,31 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
' - Fix anti-patterns, improve structure, add docstrings\n'
'3. Commit ALL changes with EXACT message:\n'
' git add -A && git commit --message="fix: auto-fix code quality [skip ci]"\n'
'4. Push:\n'
' git push origin HEAD:' + str(head_ref) + '\n\n'
'4. Do NOT push — a separate step pushes your commit.\n\n'
'CRITICAL RULES:\n'
'- ONLY modify the files listed above\n'
'- ONLY modify the files listed above (plus commit/merge resolution)\n'
'- Do NOT change program logic or add features\n'
'- Resolve merge conflicts carefully - keep BOTH sides where needed\n'
'- You are mytheclipsebotreview - git identity already set\n'
'- Use EXACTLY "fix: auto-fix code quality [skip ci]" as commit message'
'- Use EXACTLY "fix: auto-fix code quality [skip ci]" as the commit message'
)
BUFFER.append(" 🤖 Running Claude Code AI fix (" + str(AI_FIX_MAX_TURNS) + " turns max)...")
BUFFER.append(" 🤖 Running Hermes AI fix (" + str(AI_FIX_MAX_TURNS) + " turns max)...")
# Write prompt to file so user can see what Claude was asked.
# Leftover root-owned copies from the pre-switchover era cause
# PermissionError for the code user — write into our own workdir
# (we already own it) instead of shared /tmp.
log_path = workdir / ("claude_pr_" + str(pr_num) + ".prompt.txt")
# Write prompt to file so user can see what the agent was asked.
log_path = workdir / ("hermes_pr_" + str(pr_num) + ".prompt.txt")
log_path.write_text(prompt)
try:
# Re-resolve CLAUDE_BIN at invocation time so a freshly installed
# Claude Code is picked up without restarting the worker.
claude_bin = shutil.which("claude") or CLAUDE_BIN
if not os.path.isfile(claude_bin):
ok, snippet = _hermes_api_post(prompt, workdir, timeout=AI_FIX_TIMEOUT, label="hermes_pr_" + str(pr_num))
if not ok:
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin)
# If ~/.claude/settings.json already carries ANTHROPIC_BASE_URL/KEY,
# Claude Code picks them up itself; use the CLI's own config and let
# the injected env only fill what settings.json does not supply.
env = {k: v for k, v in _claude_env().items() if k in (
"PATH", "HOME", "HERMES_HOME",
"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_URL",
)}
# Run with real-time output to file
log_out = workdir / ("claude_pr_" + str(pr_num) + ".out.log")
with open(log_out, "w") as lf:
result = subprocess.run(
[claude_bin, "-p", prompt,
"--allowedTools", "Read,Edit,Bash,Write",
"--max-turns", str(AI_FIX_MAX_TURNS)],
cwd=str(workdir),
stdout=lf,
stderr=subprocess.STDOUT,
text=True,
env=env,
timeout=AI_FIX_TIMEOUT
)
# Read back for analysis
saved = log_out.read_text()
claude_output = saved[-3000:] if len(saved) > 3000 else saved
except subprocess.TimeoutExpired:
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
return False, "[INFRA] Claude Code timed out"
except FileNotFoundError:
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
return False, "[INFRA] Claude Code CLI not found"
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
pid_file.unlink(missing_ok=True)
return False, snippet
# Check if Claude made changes
# Determine whether the agent actually committed changes:
push_success = False
fix_count = 0
push_exit = result.returncode
if push_exit != 0:
BUFFER.append(" ⚠️ Claude Code exited with code " + str(push_exit))
if "git push" in claude_output.lower() or "pushed" in claude_output.lower() or "push" in claude_output.lower():
push_success = True
r3 = subprocess.run(
["git", "rev-list", "--count", str(head_sha[:12]) + "..HEAD"],
capture_output=True, text=True, timeout=10, cwd=str(workdir)
@@ -974,16 +938,27 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
except (ValueError, IndexError):
pass
if push_success:
# Worker pushes the agent's commit (agent must not push).
push_r = subprocess.run(
["git", "push", "origin", "HEAD:" + str(head_ref)],
capture_output=True, text=True, timeout=60, cwd=str(workdir)
)
if push_r.returncode != 0:
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
pid_file.unlink(missing_ok=True)
return False, "AI committed but push failed: " + (push_r.stderr or push_r.stdout or "")[:200]
# Clean up workdir + PID tracking file
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
pid_file.unlink(missing_ok=True)
if push_success:
return True, "Claude Code pushed " + str(fix_count) + " improvement commit(s)"
return True, "Hermes AI pushed " + str(fix_count) + " improvement commit(s)"
else:
snippet = claude_output[:300].replace("\n", " ")
return False, "Claude ran but no push. Output: " + snippet
return False, "Hermes ran but no commit/push. Output: " + (snippet[:300] if snippet else "")
# ══════════════════════════════════════════════════════════════════════════
# Upstream Fork Auto-Sync (2026-09-21)
@@ -1213,59 +1188,118 @@ def _push_ref(workdir, fork, source, dest, app_token, force=False):
return False, last, False
def _run_claude_sync(workdir, prompt, label, fork, dry=False):
"""Run Claude Code inside the sync workdir. Returns (ok, snippet).
def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes"):
"""Run a Hermes agent task through the local gateway API server
(OpenAI-compatible POST /v1/chat/completions on 127.0.0.1:8642).
Mirrors run_ai_fix's invocation (same binary resolution + provider env) but
with its own log names, a longer timeout (conflict resolution runs a full
test suite) and no push expectations — the harness pushes. Never spawns MCP
servers (--mcp-config ''), which have been observed hanging this worker.
The gateway (hermes-gateway.service) runs continuously and holds the
provider/9router config + full toolset. This replaces the previous
`claude -p` subprocess which failed against this gateway's provider
transport (Anthropic Messages mismatch / JSON-not-a-Message / exit 1)
and spawned hanging MCP servers.
In `dry` mode Claude is still run — the whole point of --dry is to exercise
the real conflict resolution without pushing — but every side effect
(Discord, state file, workdir cleanup) is skipped."""
claude_bin = shutil.which("claude") or CLAUDE_BIN
if not os.path.isfile(claude_bin):
return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin)
Returns (ok, snippet). snippet is the agent's final answer text.
"""
import httpx
base = os.environ.get("API_SERVER_URL", "") or API_SERVER_URL
key = os.environ.get("API_SERVER_KEY", "") or _api_server_key_from_env()
if not key:
return False, "[INFRA] Hermes API server API_SERVER_KEY not configured"
headers = {
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
}
body = {
"model": "hermes-agent",
"messages": [
{"role": "system", "content": (
"You are an autonomous coding agent inside a git worktree. "
"Use your terminal and file tools to complete the task. "
"Work ONLY inside the current working directory. Do NOT push.")},
{"role": "user", "content": prompt},
],
"stream": False,
"model_options": {"max_turns": AI_FIX_MAX_TURNS},
}
try:
with httpx.Client(timeout=timeout) as client:
r = client.post(base + "/chat/completions", headers=headers, json=body)
if r.status_code != 200:
detail = r.text[:300].replace("\n", " ")
return False, f"[INFRA] Hermes API server HTTP {r.status_code}: {detail}"
data = r.json()
choices = data.get("choices") or []
if not choices:
return False, "[INFRA] Hermes API server returned no choices: " + str(data.get("error", {}).get("message", "unknown"))[:300]
text = (choices[0].get("message") or {}).get("content") or ""
return True, (text[-4000:].replace("\n", " ") if text else "")
except httpx.ConnectError:
return False, "[INFRA] Hermes API server unreachable at " + base + " (gateway up? API_SERVER_ENABLED?)"
except httpx.TimeoutException:
return False, f"[INFRA] Hermes API server timed out after {timeout}s"
except Exception as exc:
return False, "[INFRA] Hermes API server error: " + str(exc)
def _api_server_key_from_env():
"""Read API_SERVER_KEY from ~/.hermes/.env (the gateway's profile env)."""
for dotenv_path in (
Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / ".env",
Path.home() / ".env",
):
try:
for line in dotenv_path.read_text().splitlines():
line = line.strip()
if line.startswith("API_SERVER_KEY="):
return line.partition("=")[2].strip().strip('"').strip("'")
except OSError:
continue
return ""
def _ai_fix_via_api(prompt, workdir, label, timeout=SYNC_CLAUDE_TIMEOUT):
"""Run an AI fix/resolution through the Hermes API server.
Mirrors the old claude subprocess contract: writes the prompt to a
<label>.prompt.txt in the workdir for audit, calls the API server, returns
(ok, snippet)."""
try:
(workdir / (label + ".prompt.txt")).write_text(prompt)
except OSError:
pass
return _hermes_api_post(prompt, workdir, timeout=timeout, label=label)
def _run_hermes_sync(workdir, prompt, label, fork, dry=False):
"""Run Hermes agent inside the sync workdir (conflict resolution / quality pass).
Replacement for _run_claude_sync. The API-server agent runs with the
gateway's own cwd, so to make it operate inside `workdir` we prepend the
workdir path to the prompt and instruct the agent to cd there first.
Returns (ok, snippet)."""
out_path = workdir / (label + ".out.log")
if not dry:
try:
(workdir / (label + ".prompt.txt")).write_text(prompt)
except OSError:
pass
env = {k: v for k, v in _claude_env().items() if k in (
"PATH", "HOME", "HERMES_HOME",
"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_URL",
)}
# Claude Code can hang for many minutes inside this worker when it spawns an
# MCP server that stalls (observed 2026-09-21: `ouroboros mcp serve` under
# uvx hung with zero output for 15+ minutes → the 900s timeout fired).
# --mcp-config '' alone does NOT stop it — `claude -p` still starts MCP
# servers from settings.json / managed / plugins. --strict-mcp-config
# ignores ALL other MCP configuration and confines file tools to the
# workdir. These calls only read/edit files in a throwaway clone and run
# git — no MCP server is ever needed.
mcp_args = ["--mcp-config", "", "--strict-mcp-config"]
out_path = workdir / (label + ".out.log")
try:
with open(out_path, "w") as lf:
res = subprocess.run(
[claude_bin, "-p", prompt,
"--allowedTools", "Read,Edit,Bash,Write",
"--max-turns", str(AI_FIX_MAX_TURNS)] + mcp_args,
cwd=str(workdir), stdout=lf, stderr=subprocess.STDOUT,
text=True, env=env, timeout=SYNC_CLAUDE_TIMEOUT,
prompt_with_dir = (
"Your working directory is " + str(workdir) + ". Start by running:\n"
" cd " + str(workdir) + "\n"
"Then complete the task below.\n\n" + prompt
)
saved = out_path.read_text() if out_path.exists() else ""
except subprocess.TimeoutExpired:
return False, f"[INFRA] Claude Code timed out after {SYNC_CLAUDE_TIMEOUT}s"
except FileNotFoundError:
return False, "[INFRA] Claude Code CLI not found"
except OSError as exc:
return False, f"[INFRA] Claude Code could not run: {exc}"
snippet = saved[-400:].replace("\n", " ") if saved else ""
if res.returncode != 0:
return False, f"claude exited {res.returncode}: {snippet}"
return True, snippet
ok, snippet = _hermes_api_post(prompt_with_dir, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label=label)
if ok:
try:
out_path.write_text(label + " ok: " + snippet + "\n")
except OSError:
pass
return ok, snippet
# alias so existing call sites keep working
_run_claude_sync = _run_hermes_sync
def _sync_unmerged_files(workdir):
@@ -1503,11 +1537,11 @@ def sync_fork_repo(token, fork, parent, local_branch, upstream_branch, upstream_
"skip_reason": note, "notified": False})
save_sync_state(state)
return "conflict-failed", note
BUFFER.append(f" 🤖 resolving {len(conflicted)} conflict(s) with Claude Code "
BUFFER.append(f" 🤖 resolving {len(conflicted)} conflict(s) with Hermes "
f"(up to {SYNC_CLAUDE_TIMEOUT}s)...")
ok, snippet = _run_claude_sync(
workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted),
"claude_sync_conflicts", fork, dry)
"hermes_sync_conflicts", fork, dry)
if not ok:
_sync_git(["merge", "--abort"], workdir, 60)
note = f"conflict resolution failed — {snippet[:200]}"
@@ -1525,20 +1559,20 @@ def sync_fork_repo(token, fork, parent, local_branch, upstream_branch, upstream_
"skip_reason": note, "notified": False})
save_sync_state(state)
return "conflict-failed", note
resolution = f"Claude Code resolved {len(conflicted)} conflict(s)"
resolution = f"Hermes resolved {len(conflicted)} conflict(s)"
else:
resolution = "clean merge"
if cfg.get("ai_fix_after_merge", True):
diff = _sync_git(["diff", "--name-only", f"{pre_merge_sha}..HEAD"], workdir, 120)
merged_files = [f for f in (diff.stdout or "").split("\n") if f.strip()]
if merged_files:
BUFFER.append(f" 🤖 Claude Code quality pass on {len(merged_files)} merged file(s)...")
BUFFER.append(f" 🤖 Hermes quality pass on {len(merged_files)} merged file(s)...")
ok, snippet = _run_claude_sync(
workdir, _quality_prompt(fork, parent, upstream_branch, merged_files),
"claude_sync_quality", fork, dry)
"hermes_sync_quality", fork, dry)
if ok:
if _sync_commit_if_dirty(workdir, "fix: auto-fix code quality [skip ci]"):
resolution += " + Claude Code quality pass committed"
resolution += " + Hermes quality pass committed"
else:
resolution += " + quality pass made no changes"
else:
+80 -3
View File
@@ -198,7 +198,7 @@ def test_clean_merge_synced(tmpdir):
check("pending_verify records merge sha", entry["pending_verify"]["sha"] == gitstate["head"], str(entry))
check("pending_verify records pre_merge sha", entry["pending_verify"]["pre_merge_sha"].startswith("pre"), str(entry))
check("last_merged_upstream_sha recorded", entry["last_merged_upstream_sha"] == "up1", str(entry))
check("clean merge gets a quality pass", claude_calls == ["claude_sync_quality"], str(claude_calls))
check("clean merge gets a quality pass", claude_calls == ["hermes_sync_quality"], str(claude_calls))
def test_dry_run_is_pure(tmpdir):
@@ -265,7 +265,7 @@ def test_conflict_resolved(tmpdir):
W._push_ref = lambda *a, **k: (True, "pat push ok", False)
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
check("status synced", res == "synced", f"{res} {detail}")
check("conflict runner used", labels == ["claude_sync_conflicts"], str(labels))
check("conflict runner used", labels == ["hermes_sync_conflicts"], str(labels))
check("merge completed once", finished["n"] == 1, str(finished))
check("resolution noted in detail", "resolved 2 conflict" in detail, detail)
@@ -275,7 +275,7 @@ def test_conflict_failed_skips_and_dedupes(tmpdir):
make_state_file(tmpdir)
state = {}
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Claude Code timed out")
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Hermes API server timed out")
W._push_ref = lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not push after failed resolution"))
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
entry = state["f/x"]
@@ -464,6 +464,82 @@ def test_sync_config_override():
W.UPSTREAM_SYNC["repos"] = {}
# ── 14. Hermes API-server client ──────────────────────────────────────────
def test_hermes_api_client(tmpdir):
"""The worker's Hermes client must speak OpenAI chat-completions and map
failures to [INFRA] strings the skip logic understands. Uses a real local
HTTP server (no external network)."""
print("14. Hermes API-server client (real HTTP round-trip)")
import http.server
import threading
seen = {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(length) or b"{}")
seen["path"] = self.path
seen["auth"] = self.headers.get("Authorization", "")
seen["body"] = body
if body.get("messages", [{}])[-1].get("content") == "boom":
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":{"message":"upstream exploded"}}')
return
payload = json.dumps({
"choices": [{"message": {"role": "assistant", "content": "resolved ok"}}]
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def log_message(self, *a):
pass
srv = http.server.HTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
try:
W.API_SERVER_URL = f"http://127.0.0.1:{port}/v1"
W._api_server_key_from_env = lambda: "test-key-0123456789"
ok, text = W._hermes_api_post("resolve these conflicts", tmpdir)
check("client posts to /v1/chat/completions", seen.get("path") == "/v1/chat/completions", str(seen.get("path")))
check("client sends bearer auth", seen.get("auth") == "Bearer test-key-0123456789", str(seen.get("auth")))
check("client sends openai messages shape",
seen.get("body", {}).get("messages", [{}])[0].get("role") == "system", str(seen.get("body"))[:200])
check("client caps turns via model_options",
seen.get("body", {}).get("model_options", {}).get("max_turns") == W.AI_FIX_MAX_TURNS,
str(seen.get("body", {}).get("model_options")))
check("client returns agent text", ok and text == "resolved ok", f"{ok} {text!r}")
ok2, err2 = W._hermes_api_post("boom", tmpdir)
check("HTTP error mapped to [INFRA]", (not ok2) and err2.startswith("[INFRA]") and "500" in err2, err2)
# missing key → INFRA, never a silent success
W._api_server_key_from_env = lambda: ""
saved = W.os.environ.pop("API_SERVER_KEY", None)
try:
ok3, err3 = W._hermes_api_post("x", tmpdir)
check("missing key is [INFRA]", (not ok3) and "API_SERVER_KEY" in err3, err3)
finally:
if saved is not None:
W.os.environ["API_SERVER_KEY"] = saved
# unreachable port → INFRA with guidance
W._api_server_key_from_env = lambda: "test-key-0123456789"
W.API_SERVER_URL = "http://127.0.0.1:9/v1"
ok4, err4 = W._hermes_api_post("x", tmpdir)
check("unreachable server is [INFRA]", (not ok4) and "unreachable" in err4, err4)
finally:
srv.shutdown()
srv.server_close()
W.API_SERVER_URL = "http://127.0.0.1:8642/v1"
def main():
with tempfile.TemporaryDirectory() as tmpdir:
test_upstream_status_parsing()
@@ -480,6 +556,7 @@ def main():
test_list_fork_repos()
test_push_error_classification()
test_sync_config_override()
test_hermes_api_client(pathlib.Path(tmpdir))
print(f"\n{len(PASSED)} passed, {len(FAILED)} failed")
if FAILED:
print("failed: " + ", ".join(FAILED))