Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a52c204dcf | ||
|
|
0cafe7d50d | ||
|
|
f1f42a7b9f | ||
|
|
8fdaf4d3d6 | ||
|
|
8b6501786c |
@@ -1,334 +0,0 @@
|
||||
"""OpenAI-compatible shim that forwards Hermes requests to ``codex exec --json``.
|
||||
|
||||
This adapter lets Hermes treat the OpenAI Codex CLI as a chat-style backend.
|
||||
Each request spawns ``codex exec --json --ephemeral --dangerously-bypass-approvals-and-sandbox``,
|
||||
parses the JSONL event stream, extracts the agent message text and token usage,
|
||||
and converts the result into the minimal shape Hermes expects from an OpenAI client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODEX_CLI_BASE_URL = "codex-cli://local"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 900.0
|
||||
|
||||
|
||||
def _resolve_command() -> str:
|
||||
return (
|
||||
os.getenv("HERMES_CODEX_CLI_COMMAND", "").strip()
|
||||
or os.getenv("CODEX_CLI_PATH", "").strip()
|
||||
or "codex"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_args() -> list[str]:
|
||||
raw = os.getenv("HERMES_CODEX_CLI_ARGS", "").strip()
|
||||
if not raw:
|
||||
return [
|
||||
"exec",
|
||||
"--json",
|
||||
"--ephemeral",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
]
|
||||
import shlex
|
||||
return shlex.split(raw)
|
||||
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Preserve HOME so codex can find ~/.codex/auth.json
|
||||
home = os.environ.get("HOME", "")
|
||||
if not home:
|
||||
home = os.path.expanduser("~")
|
||||
if home and home != "~":
|
||||
env["HOME"] = home
|
||||
return env
|
||||
|
||||
|
||||
def _parse_turn_completed_usage(event: dict[str, Any]) -> SimpleNamespace:
|
||||
usage = event.get("usage") or {}
|
||||
input_tokens = int(usage.get("input_tokens") or 0)
|
||||
cached_tokens = int(usage.get("cached_input_tokens") or 0)
|
||||
output_tokens = int(usage.get("output_tokens") or 0)
|
||||
reasoning_tokens = int(usage.get("reasoning_output_tokens") or 0)
|
||||
return SimpleNamespace(
|
||||
prompt_tokens=input_tokens,
|
||||
completion_tokens=output_tokens + reasoning_tokens,
|
||||
total_tokens=input_tokens + output_tokens + reasoning_tokens,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=cached_tokens),
|
||||
)
|
||||
|
||||
|
||||
class _CodexCLIChatCompletions:
|
||||
def __init__(self, client: "CodexCLIClient"):
|
||||
self._client = client
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
return self._client._create_chat_completion(**kwargs)
|
||||
|
||||
|
||||
class _CodexCLIChatNamespace:
|
||||
def __init__(self, client: "CodexCLIClient"):
|
||||
self.completions = _CodexCLIChatCompletions(client)
|
||||
|
||||
|
||||
class CodexCLIClient:
|
||||
"""Minimal OpenAI-client-compatible facade for Codex CLI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
command: str | None = None,
|
||||
args: list[str] | None = None,
|
||||
**_: Any,
|
||||
):
|
||||
self.api_key = api_key or "codex-cli"
|
||||
self.base_url = base_url or _CODEX_CLI_BASE_URL
|
||||
self._default_headers = dict(default_headers or {})
|
||||
self._command = command or _resolve_command()
|
||||
self._args = list(args or _resolve_args())
|
||||
self.chat = _CodexCLIChatNamespace(self)
|
||||
self.is_closed = False
|
||||
self._active_process: subprocess.Popen[str] | None = None
|
||||
self._active_process_lock = threading.Lock()
|
||||
|
||||
def close(self) -> None:
|
||||
proc: subprocess.Popen[str] | None
|
||||
with self._active_process_lock:
|
||||
proc = self._active_process
|
||||
self._active_process = None
|
||||
self.is_closed = True
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_prompt(self, messages: list[dict[str, Any]], model: str | None = None) -> str:
|
||||
sections: list[str] = [
|
||||
"You are being used as the active Codex CLI agent backend for Hermes.",
|
||||
"Respond to the user's request directly. Do NOT call tools — Hermes handles tools.",
|
||||
]
|
||||
if model:
|
||||
sections.append(f"Hermes requested model hint: {model}")
|
||||
|
||||
transcript: list[str] = []
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = str(message.get("role") or "unknown").strip().lower()
|
||||
content = message.get("content")
|
||||
if content is None:
|
||||
continue
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict) and "text" in item:
|
||||
parts.append(str(item["text"]))
|
||||
content = "\n".join(parts).strip()
|
||||
if not content:
|
||||
continue
|
||||
label = {
|
||||
"system": "System",
|
||||
"user": "User",
|
||||
"assistant": "Assistant",
|
||||
"tool": "Tool",
|
||||
}.get(role, role.title())
|
||||
transcript.append(f"{label}:\n{content}")
|
||||
|
||||
if transcript:
|
||||
sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript))
|
||||
|
||||
sections.append("Continue the conversation from the latest user request.")
|
||||
return "\n\n".join(s.strip() for s in sections if s and s.strip())
|
||||
|
||||
def _create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
timeout: float | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: Any = None,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
prompt_text = self._build_prompt(messages or [], model=model)
|
||||
|
||||
# Normalise timeout: run_agent.py may pass an httpx.Timeout object
|
||||
if timeout is None:
|
||||
effective_timeout = _DEFAULT_TIMEOUT_SECONDS
|
||||
elif isinstance(timeout, (int, float)):
|
||||
effective_timeout = float(timeout)
|
||||
else:
|
||||
candidates = [
|
||||
getattr(timeout, attr, None)
|
||||
for attr in ("read", "write", "connect", "pool", "timeout")
|
||||
]
|
||||
numeric = [float(v) for v in candidates if isinstance(v, (int, float))]
|
||||
effective_timeout = max(numeric) if numeric else _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
response_text, usage = self._run_prompt(prompt_text, timeout_seconds=effective_timeout)
|
||||
|
||||
assistant_message = SimpleNamespace(
|
||||
content=response_text,
|
||||
tool_calls=[],
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(message=assistant_message, finish_reason="stop")
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
model=model or "codex-cli",
|
||||
)
|
||||
|
||||
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, SimpleNamespace]:
|
||||
cmd = [self._command] + self._args
|
||||
# The prompt is a positional arg — pass it via stdin with pipe
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_build_subprocess_env(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not start Codex CLI command '{self._command}'. "
|
||||
"Install Codex CLI (npm install -g @openai/codex) or set "
|
||||
f"HERMES_CODEX_CLI_COMMAND / CODEX_CLI_PATH."
|
||||
) from exc
|
||||
|
||||
if proc.stdin is None or proc.stdout is None:
|
||||
proc.kill()
|
||||
raise RuntimeError("Codex CLI process did not expose stdin/stdout pipes.")
|
||||
|
||||
self.is_closed = False
|
||||
with self._active_process_lock:
|
||||
self._active_process = proc
|
||||
|
||||
response_parts: list[str] = []
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
total_tokens=0,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
|
||||
)
|
||||
stderr_lines: list[str] = []
|
||||
|
||||
try:
|
||||
# Write prompt to stdin and close it to signal end of input
|
||||
proc.stdin.write(prompt_text)
|
||||
proc.stdin.close()
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
stdout_thread = threading.Thread(target=lambda: None, daemon=True)
|
||||
|
||||
# Collect stdout lines
|
||||
stdout_lines: list[str] = []
|
||||
|
||||
def _read_stdout():
|
||||
if proc.stdout is None:
|
||||
return
|
||||
for line in proc.stdout:
|
||||
stdout_lines.append(line.rstrip("\n"))
|
||||
|
||||
stdout_thread = threading.Thread(target=_read_stdout, daemon=True)
|
||||
stdout_thread.start()
|
||||
|
||||
# We'll also collect stderr
|
||||
stderr_output: list[str] = []
|
||||
|
||||
def _read_stderr():
|
||||
if proc.stderr is None:
|
||||
return
|
||||
for line in proc.stderr:
|
||||
stderr_output.append(line.rstrip("\n"))
|
||||
|
||||
stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
|
||||
# Wait for process to complete or timeout
|
||||
remaining = deadline - time.monotonic()
|
||||
while remaining > 0:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
remaining = deadline - time.monotonic()
|
||||
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
raise TimeoutError("Timed out waiting for Codex CLI response.")
|
||||
|
||||
# Wait for threads to finish reading
|
||||
stdout_thread.join(timeout=5)
|
||||
stderr_thread.join(timeout=5)
|
||||
|
||||
# Parse JSONL output
|
||||
agent_text = ""
|
||||
for line in stdout_lines:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except Exception:
|
||||
# Non-JSON line (banner, status) — skip
|
||||
continue
|
||||
event_type = event.get("type", "")
|
||||
if event_type == "item.completed":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "agent_message":
|
||||
text = item.get("text") or ""
|
||||
if text:
|
||||
agent_text += text
|
||||
elif event_type == "turn.completed":
|
||||
usage = _parse_turn_completed_usage(event)
|
||||
|
||||
if agent_text:
|
||||
response_parts.append(agent_text)
|
||||
|
||||
# Stderr with useful diagnostics
|
||||
for line in stderr_output:
|
||||
if line.strip():
|
||||
stderr_lines.append(line)
|
||||
if stderr_lines and not agent_text:
|
||||
raise RuntimeError(
|
||||
"Codex CLI produced no agent message. "
|
||||
f"stderr: {'; '.join(stderr_lines[-5:])}"
|
||||
)
|
||||
|
||||
return "\n".join(response_parts).strip(), usage
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
with self._active_process_lock:
|
||||
if self._active_process is proc:
|
||||
self._active_process = None
|
||||
@@ -5450,6 +5450,88 @@ class HermesCLI:
|
||||
else:
|
||||
print("(^_^)v New session started!")
|
||||
|
||||
def _handle_handoff_command(self, cmd_original: str) -> None:
|
||||
"""Handle /handoff <platform> — hand off current session to a messaging platform."""
|
||||
from hermes_state import format_session_db_unavailable
|
||||
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
_cprint(" Usage: /handoff <platform>")
|
||||
_cprint(" Supported: telegram, discord, slack, whatsapp, signal, matrix")
|
||||
_cprint(" The session will become available on that platform's home channel.")
|
||||
return
|
||||
|
||||
platform = parts[1].strip().lower()
|
||||
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
|
||||
if platform not in supported:
|
||||
_cprint(f" Unknown platform '{platform}'. Supported: {', '.join(sorted(supported))}")
|
||||
return
|
||||
|
||||
# Ensure session is in the DB
|
||||
if not self._session_db:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
|
||||
if not self._session_db:
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
return
|
||||
|
||||
# Make sure the session has a title
|
||||
session_title = ""
|
||||
try:
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if session_meta:
|
||||
session_title = session_meta.get("title") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not session_title:
|
||||
# Auto-title from conversation if not set
|
||||
if hasattr(self, "agent") and self.agent and self.conversation_history:
|
||||
last_user_msgs = [m for m in self.conversation_history[-6:] if m.get("role") == "user"]
|
||||
if last_user_msgs:
|
||||
title = last_user_msgs[0].get("content", "")[:60]
|
||||
title = title.replace("\n", " ").strip()
|
||||
if title:
|
||||
session_title = title
|
||||
self._session_db.set_session_title(self.session_id, title)
|
||||
|
||||
if not session_title:
|
||||
session_title = "untitled session"
|
||||
|
||||
# Mark session for handoff
|
||||
ok = self._session_db.set_handoff_pending(self.session_id, platform)
|
||||
if not ok:
|
||||
_cprint(f" Session is already pending handoff or not found.")
|
||||
return
|
||||
|
||||
_cprint(f" Session '{session_title}' queued for handoff to {platform}.")
|
||||
_cprint(f" The session will resume when the next message arrives on the {platform} home channel.")
|
||||
|
||||
# Also try to send a notification via send_message
|
||||
try:
|
||||
summary_lines = ["Handoff from CLI", f"Session: {session_title}"]
|
||||
if hasattr(self, "agent") and self.agent:
|
||||
last_msgs = self.conversation_history[-4:] if self.conversation_history else []
|
||||
for msg in last_msgs:
|
||||
role = msg.get("role", "")
|
||||
content = str(msg.get("content", ""))[:120]
|
||||
if content.strip():
|
||||
summary_lines.append(f"[{role}] {content}")
|
||||
summary = "\n".join(summary_lines)
|
||||
|
||||
from tools.send_message_tool import send_message_tool
|
||||
result_json = send_message_tool({"target": platform, "message": summary})
|
||||
import json
|
||||
result = json.loads(result_json)
|
||||
if result.get("success"):
|
||||
_cprint(f" Notification sent to {platform} home channel.")
|
||||
else:
|
||||
err = result.get("error", "unknown error")
|
||||
_cprint(f" Could not send notification to {platform}: {err}")
|
||||
except Exception as e:
|
||||
_cprint(f" Could not send notification: {e}")
|
||||
|
||||
def _handle_resume_command(self, cmd_original: str) -> None:
|
||||
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
|
||||
parts = cmd_original.split(None, 1)
|
||||
@@ -6870,6 +6952,8 @@ class HermesCLI:
|
||||
else:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
elif canonical == "handoff":
|
||||
self._handle_handoff_command(cmd_original)
|
||||
elif canonical == "new":
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
title = parts[1].strip() if len(parts) > 1 else None
|
||||
|
||||
@@ -180,18 +180,32 @@ def _render_table_block_for_telegram(table_block: list[str]) -> str:
|
||||
if len(headers) < 2:
|
||||
return "\n".join(table_block)
|
||||
|
||||
# Detect row-label column: present when data rows have one more cell
|
||||
# than the header row (the row-label column carries no header).
|
||||
first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
|
||||
has_row_label_col = len(first_data_row) == len(headers) + 1
|
||||
|
||||
rendered_rows: list[str] = []
|
||||
for index, row in enumerate(table_block[2:], start=1):
|
||||
cells = _split_markdown_table_row(row)
|
||||
if len(cells) < len(headers):
|
||||
cells.extend([""] * (len(headers) - len(cells)))
|
||||
elif len(cells) > len(headers):
|
||||
cells = cells[: len(headers)]
|
||||
if has_row_label_col:
|
||||
# First cell is the row-label (heading); remaining cells align with headers.
|
||||
heading = cells[0] if cells and cells[0] else f"Row {index}"
|
||||
data_cells = cells[1:]
|
||||
else:
|
||||
# No row-label column: use first non-empty cell as heading.
|
||||
heading = next((cell for cell in cells if cell), f"Row {index}")
|
||||
data_cells = cells
|
||||
|
||||
# Pad or trim data_cells to match headers length.
|
||||
if len(data_cells) < len(headers):
|
||||
data_cells.extend([""] * (len(headers) - len(data_cells)))
|
||||
elif len(data_cells) > len(headers):
|
||||
data_cells = data_cells[: len(headers)]
|
||||
|
||||
heading = next((cell for cell in cells if cell), f"Row {index}")
|
||||
rendered_rows.append(f"**{heading}**")
|
||||
rendered_rows.extend(
|
||||
f"• {header}: {value}" for header, value in zip(headers, cells)
|
||||
f"• {header}: {value}" for header, value in zip(headers, data_cells)
|
||||
)
|
||||
|
||||
return "\n\n".join(rendered_rows)
|
||||
|
||||
@@ -6421,6 +6421,46 @@ class GatewayRunner:
|
||||
|
||||
# Build the context prompt to inject
|
||||
context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii)
|
||||
|
||||
# Check for pending CLI handoff
|
||||
if _is_new_session and self._session_db:
|
||||
try:
|
||||
platform_key = source.platform.value if source.platform else ""
|
||||
handoff = self._session_db.find_pending_handoff(platform_key)
|
||||
if handoff:
|
||||
cli_session_id = handoff["id"]
|
||||
cli_messages = self._session_db.get_messages(cli_session_id)
|
||||
if cli_messages:
|
||||
# Cap to last 200 messages to avoid context blowup
|
||||
cli_messages = cli_messages[-200:]
|
||||
transcript = []
|
||||
for msg in cli_messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content = str(msg.get("content") or "")
|
||||
if content.strip():
|
||||
label = {"user": "User", "assistant": "Assistant",
|
||||
"system": "System", "tool": "Tool"}.get(role, role.title())
|
||||
transcript.append(f"{label}: {content}")
|
||||
if transcript:
|
||||
handoff_title = handoff.get("title") or "untitled"
|
||||
handoff_context = (
|
||||
f"[Handoff from CLI session '{handoff_title}'. "
|
||||
f"Continue the conversation below where it left off.]"
|
||||
)
|
||||
context_prompt = (
|
||||
handoff_context
|
||||
+ "\n\n--- Previous conversation ---\n"
|
||||
+ "\n\n".join(transcript)
|
||||
+ "\n--- End of previous conversation ---\n\n"
|
||||
+ context_prompt
|
||||
)
|
||||
self._session_db.clear_handoff_pending(cli_session_id)
|
||||
logger.info(
|
||||
"Handoff: CLI session %s handed off to %s chat %s",
|
||||
cli_session_id, platform_key, source.chat_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Handoff check failed", exc_info=True)
|
||||
|
||||
# If the previous session expired and was auto-reset, prepend a notice
|
||||
# so the agent knows this is a fresh conversation (not an intentional /reset).
|
||||
@@ -12803,6 +12843,20 @@ class GatewayRunner:
|
||||
if isinstance(update_prompt_pending, dict):
|
||||
update_prompt_pending.pop(session_key, None)
|
||||
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
except Exception:
|
||||
_slash_confirm_mod = None
|
||||
if _slash_confirm_mod is not None:
|
||||
try:
|
||||
_slash_confirm_mod.clear(session_key)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to clear slash-confirm state for session boundary %s: %s",
|
||||
session_key,
|
||||
e,
|
||||
)
|
||||
|
||||
try:
|
||||
from tools.approval import clear_session as _clear_approval_session
|
||||
except Exception:
|
||||
|
||||
+44
-125
@@ -197,13 +197,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
|
||||
base_url_env_var="COPILOT_ACP_BASE_URL",
|
||||
),
|
||||
"codex-cli": ProviderConfig(
|
||||
id="codex-cli",
|
||||
name="OpenAI Codex CLI",
|
||||
auth_type="external_process",
|
||||
inference_base_url="codex-cli://local",
|
||||
base_url_env_var="CODEX_CLI_BASE_URL",
|
||||
),
|
||||
"gemini": ProviderConfig(
|
||||
id="gemini",
|
||||
name="Google AI Studio",
|
||||
@@ -1384,7 +1377,6 @@ def resolve_provider(
|
||||
"github": "copilot", "github-copilot": "copilot",
|
||||
"github-models": "copilot", "github-model": "copilot",
|
||||
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
||||
"codexcli": "codex-cli", "openai-codex-cli": "codex-cli",
|
||||
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
||||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
|
||||
@@ -4017,60 +4009,28 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||
if not pconfig or pconfig.auth_type != "external_process":
|
||||
return {"configured": False}
|
||||
|
||||
if provider_id == "copilot-acp":
|
||||
command = (
|
||||
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||
or "copilot"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
|
||||
if not base_url:
|
||||
base_url = pconfig.inference_base_url
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
return {
|
||||
"configured": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||
"provider": provider_id,
|
||||
"name": pconfig.name,
|
||||
"command": command,
|
||||
"args": args,
|
||||
"resolved_command": resolved_command,
|
||||
"base_url": base_url,
|
||||
"logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||
}
|
||||
command = (
|
||||
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||
or "copilot"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
|
||||
if not base_url:
|
||||
base_url = pconfig.inference_base_url
|
||||
|
||||
if provider_id == "codex-cli":
|
||||
command = (
|
||||
os.getenv("HERMES_CODEX_CLI_COMMAND", "").strip()
|
||||
or os.getenv("CODEX_CLI_PATH", "").strip()
|
||||
or "codex"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_CODEX_CLI_ARGS", "").strip()
|
||||
default_args = [
|
||||
"exec",
|
||||
"--json",
|
||||
"--ephemeral",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
]
|
||||
args = shlex.split(raw_args) if raw_args else default_args
|
||||
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
|
||||
if not base_url:
|
||||
base_url = pconfig.inference_base_url
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
return {
|
||||
"configured": bool(resolved_command),
|
||||
"provider": provider_id,
|
||||
"name": pconfig.name,
|
||||
"command": command,
|
||||
"args": args,
|
||||
"resolved_command": resolved_command,
|
||||
"base_url": base_url,
|
||||
"logged_in": bool(resolved_command),
|
||||
}
|
||||
|
||||
return {"configured": False}
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
return {
|
||||
"configured": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||
"provider": provider_id,
|
||||
"name": pconfig.name,
|
||||
"command": command,
|
||||
"args": args,
|
||||
"resolved_command": resolved_command,
|
||||
"base_url": base_url,
|
||||
"logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||
}
|
||||
|
||||
|
||||
def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
@@ -4088,8 +4048,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
return get_gemini_oauth_auth_status()
|
||||
if target == "copilot-acp":
|
||||
return get_external_process_provider_status(target)
|
||||
if target == "codex-cli":
|
||||
return get_external_process_provider_status(target)
|
||||
# API-key providers
|
||||
pconfig = PROVIDER_REGISTRY.get(target)
|
||||
if pconfig and pconfig.auth_type == "api_key":
|
||||
@@ -4163,69 +4121,30 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str,
|
||||
if not base_url:
|
||||
base_url = pconfig.inference_base_url
|
||||
|
||||
if provider_id == "copilot-acp":
|
||||
command = (
|
||||
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||
or "copilot"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
if not resolved_command and not base_url.startswith("acp+tcp://"):
|
||||
raise AuthError(
|
||||
f"Could not find the Copilot CLI command '{command}'. "
|
||||
"Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.",
|
||||
provider=provider_id,
|
||||
code="missing_copilot_cli",
|
||||
)
|
||||
return {
|
||||
"provider": provider_id,
|
||||
"api_key": "copilot-acp",
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"command": resolved_command or command,
|
||||
"args": args,
|
||||
"source": "process",
|
||||
}
|
||||
|
||||
if provider_id == "codex-cli":
|
||||
command = (
|
||||
os.getenv("HERMES_CODEX_CLI_COMMAND", "").strip()
|
||||
or os.getenv("CODEX_CLI_PATH", "").strip()
|
||||
or "codex"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_CODEX_CLI_ARGS", "").strip()
|
||||
default_args = [
|
||||
"exec",
|
||||
"--json",
|
||||
"--ephemeral",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
]
|
||||
args = shlex.split(raw_args) if raw_args else default_args
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
if not resolved_command:
|
||||
raise AuthError(
|
||||
f"Could not find the Codex CLI command '{command}'. "
|
||||
"Install Codex CLI (npm install -g @openai/codex) or set "
|
||||
"HERMES_CODEX_CLI_COMMAND / CODEX_CLI_PATH.",
|
||||
provider=provider_id,
|
||||
code="missing_codex_cli",
|
||||
)
|
||||
return {
|
||||
"provider": provider_id,
|
||||
"api_key": "codex-cli",
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"command": resolved_command or command,
|
||||
"args": args,
|
||||
"source": "process",
|
||||
}
|
||||
|
||||
raise AuthError(
|
||||
f"Unknown external-process provider '{provider_id}'.",
|
||||
provider=provider_id,
|
||||
code="unknown_external_process_provider",
|
||||
command = (
|
||||
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||
or "copilot"
|
||||
)
|
||||
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||
resolved_command = shutil.which(command) if command else None
|
||||
if not resolved_command and not base_url.startswith("acp+tcp://"):
|
||||
raise AuthError(
|
||||
f"Could not find the Copilot CLI command '{command}'. "
|
||||
"Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.",
|
||||
provider=provider_id,
|
||||
code="missing_copilot_cli",
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider_id,
|
||||
"api_key": "copilot-acp",
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"command": resolved_command or command,
|
||||
"args": args,
|
||||
"source": "process",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -79,6 +79,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||||
CommandDef("title", "Set a title for the current session", "Session",
|
||||
args_hint="[name]"),
|
||||
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
||||
args_hint="<platform>", cli_only=True),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
|
||||
+1
-1
@@ -8858,7 +8858,7 @@ def _build_provider_choices() -> list[str]:
|
||||
except Exception:
|
||||
# Fallback: static list guarantees the CLI always works
|
||||
return [
|
||||
"auto", "openrouter", "nous", "openai-codex", "copilot-acp", "codex-cli", "copilot",
|
||||
"auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot",
|
||||
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
|
||||
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
|
||||
"stepfun", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee",
|
||||
|
||||
@@ -207,17 +207,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"copilot-acp": [
|
||||
"copilot-acp",
|
||||
],
|
||||
"codex-cli": [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
],
|
||||
"copilot": [
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
@@ -810,7 +799,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
ProviderEntry("codex-cli", "OpenAI Codex CLI", "OpenAI Codex CLI (spawns `codex exec --json` — text-only MVP, Hermes tools disabled)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — native Gemini API)"),
|
||||
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (free tier supported; no API key needed)"),
|
||||
@@ -870,8 +858,6 @@ _PROVIDER_ALIASES = {
|
||||
"github-model": "copilot",
|
||||
"github-copilot-acp": "copilot-acp",
|
||||
"copilot-acp-agent": "copilot-acp",
|
||||
"codexcli": "codex-cli",
|
||||
"openai-codex-cli": "codex-cli",
|
||||
"google": "gemini",
|
||||
"google-gemini": "gemini",
|
||||
"google-ai-studio": "gemini",
|
||||
|
||||
@@ -1137,19 +1137,6 @@ def resolve_runtime_provider(
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
if provider == "codex-cli":
|
||||
creds = resolve_external_process_provider_credentials(provider)
|
||||
return {
|
||||
"provider": "codex-cli",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": creds.get("base_url", "").rstrip("/"),
|
||||
"api_key": creds.get("api_key", ""),
|
||||
"command": creds.get("command", ""),
|
||||
"args": list(creds.get("args") or []),
|
||||
"source": creds.get("source", "process"),
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
# Anthropic (native Messages API)
|
||||
if provider == "anthropic":
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
|
||||
@@ -215,6 +215,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
handoff_pending INTEGER DEFAULT 0,
|
||||
handoff_platform TEXT,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -2836,3 +2838,46 @@ class SessionDB:
|
||||
|
||||
return result
|
||||
|
||||
# ── Handoff (cross-platform session transfer) ──────────────────────────
|
||||
|
||||
def set_handoff_pending(self, session_id: str, platform: str) -> bool:
|
||||
"""Mark a session as pending handoff to the given platform.
|
||||
|
||||
Returns True if the session was found and updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE sessions SET handoff_pending = 1, handoff_platform = ? "
|
||||
"WHERE id = ? AND handoff_pending = 0",
|
||||
(platform, session_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
return self._execute_write(_do)
|
||||
|
||||
def find_pending_handoff(self, platform: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find the most recent session pending handoff for a platform.
|
||||
|
||||
Returns the session dict or None.
|
||||
"""
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"SELECT * FROM sessions "
|
||||
"WHERE handoff_pending = 1 AND handoff_platform = ? "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(platform,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def clear_handoff_pending(self, session_id: str) -> None:
|
||||
"""Clear the handoff_pending flag on a session."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET handoff_pending = 0, handoff_platform = NULL "
|
||||
"WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
|
||||
@@ -1264,7 +1264,6 @@ class AIAgent:
|
||||
api_mode is None
|
||||
and self.api_mode == "chat_completions"
|
||||
and self.provider != "copilot-acp"
|
||||
and self.provider != "codex-cli"
|
||||
and not str(self.base_url or "").lower().startswith("acp://copilot")
|
||||
and not str(self.base_url or "").lower().startswith("acp+tcp://")
|
||||
and not self._is_azure_openai_url()
|
||||
@@ -1588,9 +1587,6 @@ class AIAgent:
|
||||
if self.provider == "copilot-acp":
|
||||
client_kwargs["command"] = self.acp_command
|
||||
client_kwargs["args"] = self.acp_args
|
||||
if self.provider == "codex-cli":
|
||||
client_kwargs["command"] = self.acp_command
|
||||
client_kwargs["args"] = self.acp_args
|
||||
effective_base = base_url
|
||||
if base_url_host_matches(effective_base, "openrouter.ai"):
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
@@ -1765,11 +1761,6 @@ class AIAgent:
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
quiet_mode=self.quiet_mode,
|
||||
)
|
||||
|
||||
# Codex CLI provider is text-in/text-out MVP — Hermes tools are disabled
|
||||
# because Codex handles its own tool calling internally via `codex exec`.
|
||||
if self.provider == "codex-cli":
|
||||
self.tools = []
|
||||
|
||||
# Show tool configuration and store valid tool names for validation
|
||||
self.valid_tool_names = set()
|
||||
@@ -5968,17 +5959,6 @@ class AIAgent:
|
||||
self._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if self.provider == "codex-cli" or str(client_kwargs.get("base_url", "")).startswith("codex-cli://"):
|
||||
from agent.codex_cli_client import CodexCLIClient
|
||||
|
||||
client = CodexCLIClient(**client_kwargs)
|
||||
logger.info(
|
||||
"Codex CLI client created (%s, shared=%s) %s",
|
||||
reason,
|
||||
shared,
|
||||
self._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if self.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
|
||||
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
|
||||
|
||||
@@ -11829,10 +11809,8 @@ class AIAgent:
|
||||
# API upgrade (lines ~1083-1085).
|
||||
elif (
|
||||
self.provider == "copilot-acp"
|
||||
or self.provider == "codex-cli"
|
||||
or str(self.base_url or "").lower().startswith("acp://copilot")
|
||||
or str(self.base_url or "").lower().startswith("acp+tcp://")
|
||||
or str(self.base_url or "").lower().startswith("codex-cli://")
|
||||
):
|
||||
_use_streaming = False
|
||||
elif not self._has_stream_consumers():
|
||||
|
||||
@@ -9,6 +9,7 @@ from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
from tools import approval as approval_mod
|
||||
from tools import slash_confirm as slash_confirm_mod
|
||||
from tools.approval import (
|
||||
_ApprovalEntry,
|
||||
approve_session,
|
||||
@@ -26,6 +27,7 @@ def _clear_approval_state():
|
||||
approval_mod._session_yolo.clear()
|
||||
approval_mod._permanent_approved.clear()
|
||||
approval_mod._pending.clear()
|
||||
slash_confirm_mod._pending.clear()
|
||||
yield
|
||||
approval_mod._gateway_queues.clear()
|
||||
approval_mod._gateway_notify_cbs.clear()
|
||||
@@ -33,6 +35,7 @@ def _clear_approval_state():
|
||||
approval_mod._session_yolo.clear()
|
||||
approval_mod._permanent_approved.clear()
|
||||
approval_mod._pending.clear()
|
||||
slash_confirm_mod._pending.clear()
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
@@ -249,6 +252,15 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
"[USER INITIATED SKILLS RELOAD: other]"
|
||||
)
|
||||
|
||||
async def _target_handler(choice):
|
||||
return f"target:{choice}"
|
||||
|
||||
async def _other_handler(choice):
|
||||
return f"other:{choice}"
|
||||
|
||||
slash_confirm_mod.register(session_key, "confirm-target", "reload-mcp", _target_handler)
|
||||
slash_confirm_mod.register(other_key, "confirm-other", "reload-mcp", _other_handler)
|
||||
|
||||
runner._clear_session_boundary_security_state(session_key)
|
||||
|
||||
# Target session cleared
|
||||
@@ -257,18 +269,21 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
assert session_key not in runner._pending_skills_reload_notes
|
||||
assert slash_confirm_mod.get_pending(session_key) is None
|
||||
# Other session untouched
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
assert other_key in runner._update_prompt_pending
|
||||
assert other_key in runner._pending_skills_reload_notes
|
||||
assert slash_confirm_mod.get_pending(other_key) is not None
|
||||
|
||||
# Empty session_key is a no-op
|
||||
runner._clear_session_boundary_security_state("")
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert other_key in runner._update_prompt_pending
|
||||
assert other_key in runner._pending_skills_reload_notes
|
||||
assert slash_confirm_mod.get_pending(other_key) is not None
|
||||
|
||||
|
||||
def test_clear_session_boundary_security_state_wakes_blocked_approvals():
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Tests for the codex-cli external-process provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# CRITICAL: import directly from the module to avoid module-level side effects
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
get_external_process_provider_status,
|
||||
get_auth_status,
|
||||
resolve_external_process_provider_credentials,
|
||||
)
|
||||
|
||||
|
||||
class TestCodexCLIProviderRegistry:
|
||||
"""Test that the codex-cli provider is correctly registered."""
|
||||
|
||||
def test_provider_registered(self):
|
||||
assert "codex-cli" in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["codex-cli"]
|
||||
assert pconfig.name == "OpenAI Codex CLI"
|
||||
assert pconfig.auth_type == "external_process"
|
||||
assert pconfig.inference_base_url == "codex-cli://local"
|
||||
assert pconfig.base_url_env_var == "CODEX_CLI_BASE_URL"
|
||||
|
||||
def test_aliases_resolve(self):
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
assert resolve_provider("codexcli") == "codex-cli"
|
||||
assert resolve_provider("openai-codex-cli") == "codex-cli"
|
||||
|
||||
|
||||
class TestCodexCLIStatus:
|
||||
"""Test the external-process status helper for codex-cli."""
|
||||
|
||||
def test_status_not_configured_when_codex_missing(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
status = get_external_process_provider_status("codex-cli")
|
||||
assert status["configured"] is False
|
||||
assert status["provider"] == "codex-cli"
|
||||
|
||||
def test_status_configured_when_codex_exists(self):
|
||||
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}):
|
||||
with patch("shutil.which", return_value="/opt/homebrew/bin/codex"):
|
||||
status = get_external_process_provider_status("codex-cli")
|
||||
assert status["configured"] is True
|
||||
assert status["provider"] == "codex-cli"
|
||||
assert status["resolved_command"] == "/opt/homebrew/bin/codex"
|
||||
assert status["command"] == "codex"
|
||||
|
||||
def test_auth_status_dispatches(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
status = get_auth_status("codex-cli")
|
||||
# Should not throw, returns a dict even when not configured
|
||||
assert isinstance(status, dict)
|
||||
assert "configured" in status or "logged_in" in status
|
||||
|
||||
def test_status_with_custom_command_env(self):
|
||||
with patch.dict(os.environ, {"HERMES_CODEX_CLI_COMMAND": "/usr/local/bin/my-codex"}, clear=False):
|
||||
status = get_external_process_provider_status("codex-cli")
|
||||
assert status["command"] == "/usr/local/bin/my-codex"
|
||||
assert status["command"] == "/usr/local/bin/my-codex"
|
||||
|
||||
def test_status_with_custom_args_env(self):
|
||||
with patch.dict(os.environ, {
|
||||
"HERMES_CODEX_CLI_ARGS": "exec --json --model gpt-5.5",
|
||||
}, clear=False):
|
||||
status = get_external_process_provider_status("codex-cli")
|
||||
assert "exec" in status["args"]
|
||||
assert "--json" in status["args"]
|
||||
assert "--model" in status["args"]
|
||||
|
||||
def test_status_unknown_provider(self):
|
||||
status = get_external_process_provider_status("nonexistent")
|
||||
assert status == {"configured": False}
|
||||
|
||||
|
||||
class TestCodexCLICredentials:
|
||||
"""Test the credential resolver for codex-cli."""
|
||||
|
||||
def test_resolves_command_path_when_available(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("shutil.which", return_value="/opt/homebrew/bin/codex"):
|
||||
creds = resolve_external_process_provider_credentials("codex-cli")
|
||||
assert creds["provider"] == "codex-cli"
|
||||
assert creds["command"] == "/opt/homebrew/bin/codex"
|
||||
assert creds["api_key"] == "codex-cli"
|
||||
assert creds["base_url"] == "codex-cli://local"
|
||||
assert "--json" in creds["args"]
|
||||
assert "--ephemeral" in creds["args"]
|
||||
|
||||
def test_raises_when_command_missing(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("shutil.which", return_value=None):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
resolve_external_process_provider_credentials("codex-cli")
|
||||
assert "codex-cli" in str(exc_info.value).lower() or "codex" in str(exc_info.value).lower()
|
||||
|
||||
def test_custom_command_from_env(self):
|
||||
with patch.dict(os.environ, {"HERMES_CODEX_CLI_COMMAND": "/usr/local/bin/custom-codex"}, clear=False):
|
||||
with patch("shutil.which", return_value="/usr/local/bin/custom-codex"):
|
||||
creds = resolve_external_process_provider_credentials("codex-cli")
|
||||
assert creds["command"] == "/usr/local/bin/custom-codex"
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for session handoff (CLI to gateway platform)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
class TestHandoffDB:
|
||||
"""Test the handoff columns and helper methods on SessionDB."""
|
||||
|
||||
@pytest.fixture
|
||||
def db(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
db = SessionDB(db_path=home / "state.db")
|
||||
yield db
|
||||
|
||||
def _make_session(self, db, session_id, source="cli", title=None):
|
||||
"""Insert a session row directly for testing."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(session_id, source, title, time.time()),
|
||||
)
|
||||
db._execute_write(_do)
|
||||
|
||||
def test_handoff_columns_exist(self, db):
|
||||
"""Verify handoff columns are in the sessions table after init."""
|
||||
db._conn.execute("SELECT handoff_pending, handoff_platform FROM sessions LIMIT 0")
|
||||
|
||||
def test_set_handoff_pending(self, db):
|
||||
"""Mark a session for handoff."""
|
||||
session_id = "test-session-001"
|
||||
self._make_session(db, session_id)
|
||||
ok = db.set_handoff_pending(session_id, "telegram")
|
||||
assert ok is True
|
||||
|
||||
session = db.get_session(session_id)
|
||||
assert session["handoff_pending"] == 1
|
||||
assert session["handoff_platform"] == "telegram"
|
||||
|
||||
def test_set_handoff_pending_no_double_mark(self, db):
|
||||
"""Re-marking an already-pending session returns False."""
|
||||
session_id = "test-session-002"
|
||||
self._make_session(db, session_id)
|
||||
ok1 = db.set_handoff_pending(session_id, "telegram")
|
||||
assert ok1 is True
|
||||
ok2 = db.set_handoff_pending(session_id, "discord")
|
||||
assert ok2 is False # already pending
|
||||
|
||||
def test_find_pending_handoff(self, db):
|
||||
"""Find a session pending handoff for a given platform."""
|
||||
sid = "test-session-003"
|
||||
self._make_session(db, sid)
|
||||
db.set_handoff_pending(sid, "telegram")
|
||||
|
||||
handoff = db.find_pending_handoff("telegram")
|
||||
assert handoff is not None
|
||||
assert handoff["id"] == sid
|
||||
|
||||
# Should not find for other platforms
|
||||
assert db.find_pending_handoff("discord") is None
|
||||
|
||||
def test_clear_handoff_pending(self, db):
|
||||
"""Clear the handoff flag."""
|
||||
sid = "test-session-004"
|
||||
self._make_session(db, sid)
|
||||
db.set_handoff_pending(sid, "telegram")
|
||||
db.clear_handoff_pending(sid)
|
||||
|
||||
session = db.get_session(sid)
|
||||
assert session["handoff_pending"] == 0
|
||||
|
||||
def test_full_handoff_flow(self, db):
|
||||
"""End-to-end: mark → find → load messages → clear."""
|
||||
sid = "test-session-005"
|
||||
self._make_session(db, sid, title="my session")
|
||||
db.append_message(sid, "user", "Hello")
|
||||
db.append_message(sid, "assistant", "Hi there!")
|
||||
|
||||
# CLI side: mark for handoff
|
||||
ok = db.set_handoff_pending(sid, "telegram")
|
||||
assert ok is True
|
||||
|
||||
# Gateway side: find pending handoff
|
||||
handoff = db.find_pending_handoff("telegram")
|
||||
assert handoff is not None
|
||||
assert handoff["id"] == sid
|
||||
assert handoff["title"] == "my session"
|
||||
|
||||
# Load messages for context
|
||||
messages = db.get_messages(sid)
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[1]["role"] == "assistant"
|
||||
|
||||
# Clear after injecting
|
||||
db.clear_handoff_pending(sid)
|
||||
assert db.find_pending_handoff("telegram") is None
|
||||
|
||||
|
||||
class TestHandoffCommand:
|
||||
"""Test the CLI /handoff command handler."""
|
||||
|
||||
def test_command_registered(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("handoff")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "handoff"
|
||||
assert cmd.category == "Session"
|
||||
|
||||
def test_invalid_platform(self):
|
||||
"""Test that unknown platforms are rejected."""
|
||||
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
|
||||
assert "telegram" in supported
|
||||
assert "foo" not in supported
|
||||
Reference in New Issue
Block a user