diff --git a/README.md b/README.md index c827280..27081db 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,38 @@ source .venv/bin/activate pip install -e . ``` +## GitHub Copilot via `copilot-proxy` + +ArgusBot can route every `codex exec` call through a local `copilot-proxy` checkout, so main/reviewer/planner/BTW runs can use GitHub Copilot-backed quota instead of OpenAI API billing. + +Simplest setup: + +```bash +argusbot init +``` + +During `argusbot init` / `argusbot-setup`, ArgusBot will: + +- auto-detect an existing proxy checkout in `~/copilot-proxy`, `~/copilot-codex-proxy`, or `~/.argusbot/tools/copilot-proxy` +- if you select the `copilot` preset (or explicitly enable Copilot proxy), offer to auto-install the proxy into `~/.argusbot/tools/copilot-proxy` + +Direct CLI example: + +```bash +argusbot-run \ + --copilot-proxy \ + --main-model gpt-5.4 \ + --reviewer-model gpt-5.4 \ + --plan-model gpt-5.4 \ + "实现功能并跑完验证" +``` + +Notes: + +- `--copilot-proxy-dir` is only needed when your proxy checkout lives outside the auto-detected locations above. +- When enabled, ArgusBot auto-starts `proxy.mjs` if needed and injects Codex provider overrides per run, so you do not have to rewrite your global `~/.codex/config.toml`. +- Prefer Copilot-supported models such as `gpt-5.4`, `gpt-5.2`, `gpt-5.1`, `gpt-4o`, `claude-sonnet-4.6`, `claude-opus-4.6`, or `gemini-3-pro-preview`. + ## One-word operator workflow (`argusbot`) Run: @@ -162,6 +194,7 @@ Common options: - `--session-id `: continue an existing Codex session - `--main-model` / `--reviewer-model`: set model(s) - `--planner-model`: override the manager/planner model (defaults to reviewer settings when omitted) +- `--copilot-proxy [--copilot-proxy-port 18080] [--copilot-proxy-dir /custom/path]`: route Codex through local `copilot-proxy` - `python -m codex_autoloop.model_catalog`: list common models and presets - `--yolo`: pass dangerous no-sandbox mode to Codex - `--full-auto`: pass full-auto mode to Codex @@ -491,6 +524,7 @@ python -m codex_autoloop.model_catalog Current presets: - `quality`: `main=gpt-5.4/high`, `reviewer=gpt-5.4/high` +- `copilot`: `main=gpt-5.4/high`, `reviewer=gpt-5.4/high` - `codex52-xhigh`: `main=gpt-5.2-codex/xhigh`, `reviewer=gpt-5.2-codex/xhigh` - `quality-xhigh`: `main=gpt-5.4/xhigh`, `reviewer=gpt-5.4/xhigh` - `balanced`: `main=gpt-5.3-codex/high`, `reviewer=gpt-5.1-codex/medium` diff --git a/codex_autoloop/apps/cli_app.py b/codex_autoloop/apps/cli_app.py index 461e5eb..944e8ee 100644 --- a/codex_autoloop/apps/cli_app.py +++ b/codex_autoloop/apps/cli_app.py @@ -14,7 +14,7 @@ TerminalEventSink, ) from ..btw_agent import BtwAgent, BtwConfig -from ..codex_runner import CodexRunner +from ..copilot_proxy import build_codex_runner, config_from_args, format_proxy_summary from ..core.engine import LoopConfig, LoopEngine from ..core.state_store import LoopStateStore from ..dashboard import DashboardServer, DashboardStore @@ -40,6 +40,7 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]: dashboard_server: DashboardServer | None = None event_sink: CompositeEventSink | None = None control_channels: list[object] = [] + copilot_proxy = config_from_args(args) operator_messages_file = resolve_operator_messages_file( explicit_path=args.operator_messages_file, @@ -92,6 +93,9 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]: ) sinks.append(DashboardEventSink(dashboard_store)) + if copilot_proxy.enabled: + print(f"Copilot proxy mode: {format_proxy_summary(copilot_proxy)}", file=sys.stderr) + telegram_notifier: TelegramNotifier | None = None raw_chat_id = (args.telegram_chat_id or "").strip() telegram_requested = bool(args.telegram_bot_token) or raw_chat_id.lower() not in {"", "auto"} @@ -230,7 +234,7 @@ def reply_to_source(source: str, message: str) -> None: print(message, file=sys.stderr) btw_agent = BtwAgent( - runner=CodexRunner(codex_bin=args.codex_bin), + runner=build_codex_runner(codex_bin=args.codex_bin, config=copilot_proxy), config=BtwConfig( working_dir=str(Path.cwd()), model=args.plan_model or args.reviewer_model or args.main_model, @@ -377,7 +381,11 @@ def on_control_command(command) -> None: channel.start(on_control_command) event_sink = CompositeEventSink(sinks) - runner = CodexRunner(codex_bin=args.codex_bin, event_callback=event_sink.handle_stream_line) + runner = build_codex_runner( + codex_bin=args.codex_bin, + config=copilot_proxy, + event_callback=event_sink.handle_stream_line, + ) reviewer = Reviewer(runner=runner) planner = Planner(runner=runner) if args.plan_mode != "off" else None engine = LoopEngine( diff --git a/codex_autoloop/apps/daemon_app.py b/codex_autoloop/apps/daemon_app.py index ca78d39..830d01e 100644 --- a/codex_autoloop/apps/daemon_app.py +++ b/codex_autoloop/apps/daemon_app.py @@ -12,7 +12,7 @@ from ..adapters.control_channels import LocalBusControlChannel, TelegramControlChannel from ..btw_agent import BtwAgent, BtwConfig -from ..codex_runner import CodexRunner +from ..copilot_proxy import build_codex_runner, config_from_args, format_proxy_summary from ..daemon_bus import BusCommand, JsonlCommandBus, read_status, write_status from ..model_catalog import get_preset from ..telegram_notifier import TelegramConfig, TelegramNotifier, resolve_chat_id @@ -48,6 +48,9 @@ def __init__(self, args: argparse.Namespace) -> None: self.status_path = self.bus_dir / "daemon_status.json" self.next_run_new_session_flag_path = self.bus_dir / "next_run_new_session.flag" preset = get_preset(args.run_model_preset) if getattr(args, "run_model_preset", None) else None + run_copilot_proxy = config_from_args(args, prefix="run_") + if run_copilot_proxy.enabled: + print(f"[daemon] Copilot proxy mode: {format_proxy_summary(run_copilot_proxy)}", file=sys.stderr) self.token_lock: TokenLock | None = None self.notifier: TelegramNotifier | None = None @@ -62,7 +65,7 @@ def __init__(self, args: argparse.Namespace) -> None: self.child_started_at: dt.datetime | None = None self.child_control_bus: JsonlCommandBus | None = None self.btw_agent = BtwAgent( - runner=CodexRunner(), + runner=build_codex_runner(codex_bin="codex", config=run_copilot_proxy), config=BtwConfig( working_dir=str(self.run_cwd), model=( @@ -600,6 +603,14 @@ def build_child_command( cmd.append("--no-telegram-live-updates") if args.telegram_control_whisper_api_key: cmd.extend(["--telegram-control-whisper-api-key", args.telegram_control_whisper_api_key]) + if getattr(args, "run_copilot_proxy", False): + cmd.append("--copilot-proxy") + else: + cmd.append("--no-copilot-proxy") + run_copilot_proxy_dir = str(getattr(args, "run_copilot_proxy_dir", "") or "").strip() + if run_copilot_proxy_dir: + cmd.extend(["--copilot-proxy-dir", run_copilot_proxy_dir]) + cmd.extend(["--copilot-proxy-port", str(int(getattr(args, "run_copilot_proxy_port", 18080)))]) if resume_session_id: cmd.extend(["--session-id", resume_session_id]) if args.run_skip_git_repo_check: diff --git a/codex_autoloop/cli.py b/codex_autoloop/cli.py index b678d28..1e64d71 100644 --- a/codex_autoloop/cli.py +++ b/codex_autoloop/cli.py @@ -4,6 +4,7 @@ import json from pathlib import Path +from .copilot_proxy import AUTO_DETECTED_PROXY_DIR_HELP from .apps.cli_app import run_cli from .apps.shell_utils import ( control_help_text, @@ -79,6 +80,23 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("objective", nargs="+", help="Task objective passed to the primary agent.") parser.add_argument("--codex-bin", default="codex", help="Codex CLI binary path.") + parser.add_argument( + "--copilot-proxy", + action=argparse.BooleanOptionalAction, + default=False, + help="Route Codex CLI requests through a local copilot-proxy instance.", + ) + parser.add_argument( + "--copilot-proxy-dir", + default=None, + help=f"Path to the local copilot-proxy checkout. {AUTO_DETECTED_PROXY_DIR_HELP}", + ) + parser.add_argument( + "--copilot-proxy-port", + type=int, + default=18080, + help="Local copilot-proxy port.", + ) parser.add_argument("--session-id", default=None, help="Resume an existing Codex exec session id.") parser.add_argument("--max-rounds", type=int, default=500, help="Maximum primary-agent rounds.") parser.add_argument( diff --git a/codex_autoloop/codex_runner.py b/codex_autoloop/codex_runner.py index 7963b6f..d9f1610 100644 --- a/codex_autoloop/codex_runner.py +++ b/codex_autoloop/codex_runner.py @@ -49,9 +49,17 @@ class RunnerOptions: class CodexRunner: - def __init__(self, codex_bin: str = "codex", event_callback: EventCallback | None = None) -> None: + def __init__( + self, + codex_bin: str = "codex", + event_callback: EventCallback | None = None, + default_extra_args: list[str] | None = None, + before_exec: Callable[[], None] | None = None, + ) -> None: self.codex_bin = codex_bin self.event_callback = event_callback + self.default_extra_args = list(default_extra_args or []) + self.before_exec = before_exec def run_exec( self, @@ -61,6 +69,8 @@ def run_exec( options: RunnerOptions, run_label: str | None = None, ) -> CodexRunResult: + if self.before_exec is not None: + self.before_exec() command = self._build_command(prompt=prompt, resume_thread_id=resume_thread_id, options=options) command[0] = self._resolve_executable(command[0]) process = subprocess.Popen( @@ -275,8 +285,11 @@ def _build_command(self, *, prompt: str, resume_thread_id: str | None, options: command.append("--skip-git-repo-check") if options.output_schema_path and not resume_thread_id: command.extend(["--output-schema", options.output_schema_path]) + merged_extra_args = [*self.default_extra_args] if options.extra_args: - command.extend(options.extra_args) + merged_extra_args.extend(options.extra_args) + if merged_extra_args: + command.extend(merged_extra_args) if resume_thread_id: command.append(resume_thread_id) # Always stream the prompt through stdin so multiline prompts survive diff --git a/codex_autoloop/codexloop.py b/codex_autoloop/codexloop.py index 32276e7..7b4d6dd 100644 --- a/codex_autoloop/codexloop.py +++ b/codex_autoloop/codexloop.py @@ -15,6 +15,7 @@ from typing import Any from .apps.daemon_app import render_plan_context, render_review_context +from .copilot_proxy import bootstrap_proxy_checkout, managed_proxy_dir, resolve_proxy_dir from .daemon_bus import BusCommand, JsonlCommandBus, read_status from .model_catalog import MODEL_PRESETS @@ -336,6 +337,9 @@ def run_interactive_config(*, home_dir: Path, run_cd: Path) -> dict[str, Any]: feishu_chat_id = prompt_input("Feishu chat id: ", default="").strip() or None check_cmd = prompt_input("Default check command (optional): ", default="").strip() model_preset = prompt_model_choice() + use_copilot_proxy, copilot_proxy_dir, copilot_proxy_port = prompt_copilot_proxy_choice( + preferred=(model_preset == "copilot") + ) play_mode = prompt_play_mode() print(f"Run working directory: {run_cd}") return { @@ -362,6 +366,9 @@ def run_interactive_config(*, home_dir: Path, run_cd: Path) -> dict[str, Any]: "run_main_model": None, "run_reviewer_model": None, "run_model_preset": model_preset, + "run_copilot_proxy": use_copilot_proxy, + "run_copilot_proxy_dir": copilot_proxy_dir, + "run_copilot_proxy_port": copilot_proxy_port, "bus_dir": str((home_dir / "bus").resolve()), "logs_dir": str((home_dir / "logs").resolve()), } @@ -457,6 +464,36 @@ def prompt_model_choice() -> str | None: print("Selection out of range. Please choose one of the listed numbers.", file=sys.stderr) +def prompt_copilot_proxy_choice(*, preferred: bool = False) -> tuple[bool, str | None, int]: + detected = resolve_proxy_dir() + if detected is not None: + use_proxy = prompt_yes_no( + f"Detected copilot-proxy at {detected}. Use it for Codex runs?", + default=preferred, + ) + if not use_proxy: + return False, None, 18080 + return True, str(detected), 18080 + if not preferred: + return False, None, 18080 + target_dir = managed_proxy_dir() + install_proxy = prompt_yes_no( + f"No local copilot-proxy checkout was found. Install it automatically into {target_dir}?", + default=True, + ) + if not install_proxy: + return False, None, 18080 + try: + resolved = bootstrap_proxy_checkout( + on_progress=lambda message: print(f"[copilot-proxy] {message}", file=sys.stderr), + ) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return False, None, 18080 + print(f"[copilot-proxy] Ready at {resolved}", file=sys.stderr) + return True, str(resolved), 18080 + + def prompt_play_mode() -> PlayMode: print("Choose Planner Mode:") for idx, mode in enumerate(PLAY_MODES, start=1): @@ -720,6 +757,15 @@ def build_daemon_command(*, config: dict[str, Any], home_dir: Path, token_lock_d run_model_preset = str(config.get("run_model_preset") or "").strip() if run_model_preset: cmd.extend(["--run-model-preset", run_model_preset]) + if bool(config.get("run_copilot_proxy")): + cmd.append("--run-copilot-proxy") + else: + cmd.append("--no-run-copilot-proxy") + run_copilot_proxy_dir = str(config.get("run_copilot_proxy_dir") or "").strip() + if run_copilot_proxy_dir: + cmd.extend(["--run-copilot-proxy-dir", run_copilot_proxy_dir]) + run_copilot_proxy_port = int(config.get("run_copilot_proxy_port") or 18080) + cmd.extend(["--run-copilot-proxy-port", str(run_copilot_proxy_port)]) run_plan_record_file = str(config.get("run_plan_record_file") or "").strip() if run_plan_record_file: cmd.extend(["--run-plan-record-file", run_plan_record_file]) diff --git a/codex_autoloop/copilot_proxy.py b/codex_autoloop/copilot_proxy.py new file mode 100644 index 0000000..309e881 --- /dev/null +++ b/codex_autoloop/copilot_proxy.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import shlex +import shutil +import subprocess +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from .codex_runner import CodexRunner + + +DEFAULT_COPILOT_PROXY_PORT = 18080 +DEFAULT_COPILOT_PROVIDER = "copilot" +DEFAULT_COPILOT_PROXY_REPO_URL = "https://github.com/lbx154/copilot-codex-proxy.git" +AUTO_DETECTED_PROXY_DIRS = "~/copilot-proxy, ~/copilot-codex-proxy, or ~/.argusbot/tools/copilot-proxy" +AUTO_DETECTED_PROXY_DIR_HELP = f"Auto-detects {AUTO_DETECTED_PROXY_DIRS}." + + +@dataclass(frozen=True) +class CopilotProxyConfig: + enabled: bool = False + proxy_dir: str | None = None + port: int = DEFAULT_COPILOT_PROXY_PORT + provider_name: str = DEFAULT_COPILOT_PROVIDER + log_file: str | None = None + + def resolved_proxy_dir(self) -> Path | None: + return resolve_proxy_dir(self.proxy_dir) + + def resolved_log_file(self) -> Path | None: + if self.log_file: + return Path(self.log_file).expanduser().resolve() + proxy_dir = self.resolved_proxy_dir() + if proxy_dir is None: + return None + return proxy_dir / "proxy.log" + + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}/v1" + + def health_url(self) -> str: + return f"http://127.0.0.1:{self.port}/health" + + +def resolve_proxy_dir(raw: str | None = None) -> Path | None: + if raw: + candidate = Path(raw).expanduser().resolve() + if (candidate / "proxy.mjs").exists(): + return candidate + return None + for candidate in _default_proxy_dir_candidates(): + if (candidate / "proxy.mjs").exists(): + return candidate + return None + + +def _default_proxy_dir_candidates() -> list[Path]: + home = Path.home() + return [ + (home / "copilot-proxy").resolve(), + (home / "copilot-codex-proxy").resolve(), + managed_proxy_dir(), + ] + + +def managed_proxy_dir() -> Path: + return (Path.home() / ".argusbot" / "tools" / "copilot-proxy").resolve() + + +def bootstrap_proxy_checkout( + *, + target_dir: str | Path | None = None, + repo_url: str = DEFAULT_COPILOT_PROXY_REPO_URL, + on_progress: Callable[[str], None] | None = None, +) -> Path: + target = Path(target_dir).expanduser().resolve() if target_dir is not None else managed_proxy_dir() + git_bin = shutil.which("git") + if not git_bin: + raise RuntimeError("Copilot proxy bootstrap requires `git` in PATH.") + node_bin = shutil.which("node") + if not node_bin: + raise RuntimeError("Copilot proxy bootstrap requires `node` in PATH.") + + try: + if target.exists(): + if (target / "proxy.mjs").exists(): + _emit_progress(on_progress, f"Running copilot-proxy setup in {target}") + _run_setup(node_bin=node_bin, proxy_dir=target) + return target + if any(target.iterdir()): + raise RuntimeError( + f"Copilot proxy bootstrap target is not empty: {target}. " + "Set --copilot-proxy-dir to an existing checkout or remove that directory first." + ) + else: + target.parent.mkdir(parents=True, exist_ok=True) + + _emit_progress(on_progress, f"Cloning copilot-proxy into {target}") + subprocess.run([git_bin, "clone", repo_url, str(target)], check=True) + _emit_progress(on_progress, f"Running copilot-proxy setup in {target}") + _run_setup(node_bin=node_bin, proxy_dir=target) + return target + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"Copilot proxy bootstrap failed with exit code {exc.returncode}." + ) from exc + + +def _emit_progress(on_progress: Callable[[str], None] | None, message: str) -> None: + if on_progress is not None: + on_progress(message) + + +def _run_setup(*, node_bin: str, proxy_dir: Path) -> None: + setup_script = proxy_dir / "setup.mjs" + if not setup_script.exists(): + raise RuntimeError(f"Copilot proxy checkout is missing setup.mjs: {proxy_dir}") + subprocess.run([node_bin, str(setup_script)], cwd=str(proxy_dir), check=True) + + +def config_from_args(args: Any, *, prefix: str = "") -> CopilotProxyConfig: + enabled = bool(getattr(args, f"{prefix}copilot_proxy", False)) + provider_name = str(getattr(args, f"{prefix}copilot_provider_name", DEFAULT_COPILOT_PROVIDER) or DEFAULT_COPILOT_PROVIDER) + provider_name = provider_name.strip() or DEFAULT_COPILOT_PROVIDER + raw_port = getattr(args, f"{prefix}copilot_proxy_port", DEFAULT_COPILOT_PROXY_PORT) + try: + port = int(raw_port) + except (TypeError, ValueError): + port = DEFAULT_COPILOT_PROXY_PORT + return CopilotProxyConfig( + enabled=enabled, + proxy_dir=getattr(args, f"{prefix}copilot_proxy_dir", None), + port=max(1, port), + provider_name=provider_name, + log_file=getattr(args, f"{prefix}copilot_proxy_log_file", None), + ) + + +def codex_config_overrides(config: CopilotProxyConfig) -> list[str]: + if not config.enabled: + return [] + provider = config.provider_name + return [ + "-c", + f'model_provider="{provider}"', + "-c", + f'model_providers.{provider}.name="GitHub Copilot"', + "-c", + f'model_providers.{provider}.base_url="{config.base_url()}"', + "-c", + f'model_providers.{provider}.wire_api="responses"', + "-c", + f"model_providers.{provider}.requires_openai_auth=false", + ] + + +def proxy_is_healthy(config: CopilotProxyConfig, *, timeout_seconds: float = 2.0) -> bool: + req = urllib.request.Request(config.health_url(), method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: + return resp.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def ensure_proxy_running( + config: CopilotProxyConfig, + *, + startup_timeout_seconds: float = 15.0, +) -> None: + if not config.enabled: + return + if proxy_is_healthy(config): + return + proxy_dir = config.resolved_proxy_dir() + if proxy_dir is None: + raise RuntimeError( + "Copilot proxy is enabled but proxy.mjs was not found. " + "Set --copilot-proxy-dir to a valid copilot-proxy checkout." + ) + node_bin = shutil.which("node") + if not node_bin: + raise RuntimeError("Copilot proxy is enabled but `node` was not found in PATH.") + log_path = config.resolved_log_file() + if log_path is None: + raise RuntimeError("Copilot proxy log path could not be resolved.") + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as log_file: + subprocess.Popen( + [node_bin, str(proxy_dir / "proxy.mjs"), "--port", str(config.port)], + cwd=str(proxy_dir), + stdout=log_file, + stderr=log_file, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + max(1.0, startup_timeout_seconds) + while time.monotonic() < deadline: + if proxy_is_healthy(config): + return + time.sleep(0.5) + raise RuntimeError( + "Copilot proxy failed to start or become healthy. " + f"Check {log_path} for details." + ) + + +def build_codex_runner( + *, + codex_bin: str, + config: CopilotProxyConfig, + event_callback=None, +) -> CodexRunner: + if not config.enabled: + return CodexRunner(codex_bin=codex_bin, event_callback=event_callback) + return CodexRunner( + codex_bin=codex_bin, + event_callback=event_callback, + default_extra_args=codex_config_overrides(config), + before_exec=lambda: ensure_proxy_running(config), + ) + + +def prompt_for_proxy_dir(raw: str) -> str | None: + value = raw.strip() + if not value: + return None + resolved = resolve_proxy_dir(value) + if resolved is None: + return None + return str(resolved) + + +def format_proxy_summary(config: CopilotProxyConfig) -> str: + if not config.enabled: + return "disabled" + proxy_dir = config.resolved_proxy_dir() + return ( + f"enabled provider={config.provider_name} " + f"base_url={config.base_url()} " + f"dir={proxy_dir or '-'}" + ) + + +def shell_args_for_display(config: CopilotProxyConfig) -> str: + if not config.enabled: + return "" + parts = [ + "--copilot-proxy", + "--copilot-proxy-port", + str(config.port), + ] + if config.proxy_dir: + parts.extend(["--copilot-proxy-dir", config.proxy_dir]) + return shlex.join(parts) diff --git a/codex_autoloop/model_catalog.py b/codex_autoloop/model_catalog.py index aaa0a58..45f6678 100644 --- a/codex_autoloop/model_catalog.py +++ b/codex_autoloop/model_catalog.py @@ -50,6 +50,16 @@ class ModelEntry: plan_reasoning_effort="high", note="Highest-quality default with high reasoning for both agents.", ), + ModelPreset( + name="copilot", + main_model="gpt-5.4", + main_reasoning_effort="high", + reviewer_model="gpt-5.4", + reviewer_reasoning_effort="high", + plan_model="gpt-5.4", + plan_reasoning_effort="high", + note="Copilot proxy-friendly preset using GPT-5.4 across main, reviewer, and planner.", + ), ModelPreset( name="codex52-xhigh", main_model="gpt-5.2-codex", diff --git a/codex_autoloop/setup_wizard.py b/codex_autoloop/setup_wizard.py index 1dfbe2d..241e9e4 100644 --- a/codex_autoloop/setup_wizard.py +++ b/codex_autoloop/setup_wizard.py @@ -11,8 +11,19 @@ import sys import time from pathlib import Path -from typing import Any - +from typing import Any, Callable + +from .copilot_proxy import ( + AUTO_DETECTED_PROXY_DIRS, + AUTO_DETECTED_PROXY_DIR_HELP, + CopilotProxyConfig, + bootstrap_proxy_checkout, + codex_config_overrides, + ensure_proxy_running, + format_proxy_summary, + managed_proxy_dir, + resolve_proxy_dir, +) from .model_catalog import MODEL_PRESETS, get_preset from .planner_modes import ( PLANNER_MODE_AUTO, @@ -46,13 +57,6 @@ def main() -> None: print("codex CLI executable check failed.", file=sys.stderr) raise SystemExit(2) - auth_ok = check_codex_auth(codex_bin=codex_bin, cwd=Path(args.run_cd).resolve(), timeout_seconds=45) - if not auth_ok: - print("Could not verify Codex auth by probe request.", file=sys.stderr) - choice = prompt_input("Continue anyway? [y/N]: ", default="n").lower() - if choice not in {"y", "yes"}: - raise SystemExit(2) - channel = resolve_setup_channel(args) home_dir = Path(args.home_dir).resolve() bus_dir = home_dir / "bus" @@ -78,6 +82,27 @@ def main() -> None: preset_name = prompt_model_choice() if preset_name is None: inherit_codex_defaults = True + use_copilot_proxy, copilot_proxy_dir, copilot_proxy_port = resolve_copilot_proxy_settings( + args, + preferred_preset_name=preset_name, + ) + copilot_proxy = CopilotProxyConfig( + enabled=use_copilot_proxy, + proxy_dir=copilot_proxy_dir, + port=copilot_proxy_port, + ) + auth_ok = check_codex_auth( + codex_bin=codex_bin, + cwd=Path(args.run_cd).resolve(), + timeout_seconds=45, + extra_args=codex_config_overrides(copilot_proxy), + before_exec=(lambda: ensure_proxy_running(copilot_proxy)) if copilot_proxy.enabled else None, + ) + if not auth_ok: + print("Could not verify Codex auth by probe request.", file=sys.stderr) + choice = prompt_input("Continue anyway? [y/N]: ", default="n").lower() + if choice not in {"y", "yes"}: + raise SystemExit(2) resolved_preset = get_preset(preset_name) if preset_name and preset_name.lower() != "custom" else None if preset_name and preset_name.lower() != "custom" and resolved_preset is None: print(f"Unknown model preset: {preset_name}", file=sys.stderr) @@ -161,6 +186,9 @@ def main() -> None: "run_main_model": main_model, "run_reviewer_model": reviewer_model, "run_model_preset": (resolved_preset.name if resolved_preset else (preset_name or None)), + "run_copilot_proxy": copilot_proxy.enabled, + "run_copilot_proxy_dir": copilot_proxy.proxy_dir, + "run_copilot_proxy_port": copilot_proxy.port, "run_planner_mode": planner_mode, "follow_up_auto_execute_seconds": args.follow_up_auto_execute_seconds, "codex_autoloop_bin": DEFAULT_CODEX_AUTOLOOP_CMD, @@ -227,6 +255,13 @@ def main() -> None: daemon_cmd.extend(["--run-reviewer-model", reviewer_model]) if reviewer_reasoning_effort: daemon_cmd.extend(["--run-reviewer-reasoning-effort", reviewer_reasoning_effort]) + if copilot_proxy.enabled: + daemon_cmd.append("--run-copilot-proxy") + else: + daemon_cmd.append("--no-run-copilot-proxy") + if copilot_proxy.proxy_dir: + daemon_cmd.extend(["--run-copilot-proxy-dir", copilot_proxy.proxy_dir]) + daemon_cmd.extend(["--run-copilot-proxy-port", str(copilot_proxy.port)]) if args.run_skip_git_repo_check: daemon_cmd.append("--run-skip-git-repo-check") if args.run_full_auto: @@ -280,6 +315,7 @@ def main() -> None: f"Reviewer model: {reviewer_model or ''} " f"effort={reviewer_reasoning_effort or ''}" ) + print(f"Copilot proxy: {format_proxy_summary(copilot_proxy)}") print("") ctl_hint = resolve_daemon_ctl_hint() print("Terminal control examples:") @@ -316,10 +352,26 @@ def check_codex_binary(codex_bin: str) -> bool: return completed.returncode == 0 -def check_codex_auth(*, codex_bin: str, cwd: Path, timeout_seconds: int) -> bool: +def check_codex_auth( + *, + codex_bin: str, + cwd: Path, + timeout_seconds: int, + extra_args: list[str] | None = None, + before_exec: Callable[[], None] | None = None, +) -> bool: try: + if before_exec is not None: + before_exec() completed = subprocess.run( - [codex_bin, "exec", "--skip-git-repo-check", "--json", "Reply exactly: ok"], + [ + codex_bin, + "exec", + "--skip-git-repo-check", + "--json", + *(extra_args or []), + "Reply exactly: ok", + ], cwd=str(cwd), capture_output=True, text=True, @@ -334,6 +386,71 @@ def check_codex_auth(*, codex_bin: str, cwd: Path, timeout_seconds: int) -> bool return '"text":"ok"' in text or '"text": "ok"' in text +def resolve_copilot_proxy_settings( + args: argparse.Namespace, + *, + preferred_preset_name: str | None = None, +) -> tuple[bool, str | None, int]: + explicit_enabled = getattr(args, "run_copilot_proxy", None) + raw_dir = getattr(args, "run_copilot_proxy_dir", None) + raw_port = getattr(args, "run_copilot_proxy_port", 18080) + resolved_dir = resolve_proxy_dir(raw_dir) + copilot_preferred = str(preferred_preset_name or getattr(args, "run_model_preset", "") or "").strip().lower() == "copilot" + if raw_dir and resolved_dir is None: + print( + "Invalid --run-copilot-proxy-dir: proxy.mjs not found in the specified directory.", + file=sys.stderr, + ) + raise SystemExit(2) + if explicit_enabled is True: + if resolved_dir is None: + installed = prompt_install_copilot_proxy(default=True) + if installed: + resolved_dir = Path(installed) + if resolved_dir is None: + print( + "Copilot proxy was enabled, but no local proxy checkout was found. " + f"Pass --run-copilot-proxy-dir explicitly or use one of these locations: {AUTO_DETECTED_PROXY_DIRS}.", + file=sys.stderr, + ) + raise SystemExit(2) + return True, str(resolved_dir), max(1, int(raw_port)) + if explicit_enabled is False: + return False, None, max(1, int(raw_port)) + if resolved_dir is None: + if copilot_preferred: + installed = prompt_install_copilot_proxy(default=True) + if installed: + return True, installed, max(1, int(raw_port)) + return False, None, max(1, int(raw_port)) + use_proxy = prompt_yes_no( + f"Detected copilot-proxy at {resolved_dir}. Use it for Codex runs?", + default=copilot_preferred, + ) + if not use_proxy: + return False, None, max(1, int(raw_port)) + return True, str(resolved_dir), max(1, int(raw_port)) + + +def prompt_install_copilot_proxy(*, default: bool) -> str | None: + target_dir = managed_proxy_dir() + install_proxy = prompt_yes_no( + f"No local copilot-proxy checkout was found. Install it automatically into {target_dir}?", + default=default, + ) + if not install_proxy: + return None + try: + resolved = bootstrap_proxy_checkout( + on_progress=lambda message: print(f"[copilot-proxy] {message}", file=sys.stderr), + ) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return None + print(f"[copilot-proxy] Ready at {resolved}", file=sys.stderr) + return str(resolved) + + def resolve_daemon_launch_prefix() -> list[str]: override = os.environ.get("CODEX_AUTOLOOP_DAEMON_BIN", "").strip() if override: @@ -835,6 +952,23 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Optional preset name for daemon-launched models. Interactive setup also prompts for this.", ) + parser.add_argument( + "--run-copilot-proxy", + action=argparse.BooleanOptionalAction, + default=None, + help="Use a local copilot-proxy for daemon-launched Codex runs.", + ) + parser.add_argument( + "--run-copilot-proxy-dir", + default=None, + help=f"Path to the local copilot-proxy checkout. {AUTO_DETECTED_PROXY_DIR_HELP}", + ) + parser.add_argument( + "--run-copilot-proxy-port", + type=int, + default=18080, + help="Local copilot-proxy port.", + ) parser.add_argument( "--run-planner-mode", default=None, diff --git a/codex_autoloop/telegram_daemon.py b/codex_autoloop/telegram_daemon.py index fbf95c8..49d0060 100644 --- a/codex_autoloop/telegram_daemon.py +++ b/codex_autoloop/telegram_daemon.py @@ -16,7 +16,7 @@ from .apps.daemon_app import render_plan_context, render_review_context from .apps.shell_utils import format_mode_menu from .btw_agent import BtwAgent, BtwConfig -from .codex_runner import CodexRunner +from .copilot_proxy import AUTO_DETECTED_PROXY_DIR_HELP, build_codex_runner, config_from_args, format_proxy_summary from .daemon_bus import BusCommand, JsonlCommandBus, read_status, write_status from .feishu_adapter import FeishuCommand, FeishuCommandPoller, FeishuConfig, FeishuNotifier from .model_catalog import MODEL_PRESETS, get_preset @@ -311,8 +311,11 @@ def main() -> None: pending_follow_up: PlanFollowUp | None = None feishu_heartbeat_interval_seconds = max(0, int(args.feishu_heartbeat_interval_seconds)) last_feishu_heartbeat_monotonic = time.monotonic() + run_copilot_proxy = config_from_args(args, prefix="run_") + if run_copilot_proxy.enabled: + print(f"[daemon] Copilot proxy mode: {format_proxy_summary(run_copilot_proxy)}", file=sys.stderr) btw_agent = BtwAgent( - runner=CodexRunner(), + runner=build_codex_runner(codex_bin="codex", config=run_copilot_proxy), config=BtwConfig( working_dir=str(run_cwd), model=( @@ -1297,6 +1300,14 @@ def build_child_command( cmd.append("--no-telegram-control-whisper") if args.telegram_control_whisper_api_key: cmd.extend(["--telegram-control-whisper-api-key", args.telegram_control_whisper_api_key]) + if getattr(args, "run_copilot_proxy", False): + cmd.append("--copilot-proxy") + else: + cmd.append("--no-copilot-proxy") + run_copilot_proxy_dir = str(getattr(args, "run_copilot_proxy_dir", "") or "").strip() + if run_copilot_proxy_dir: + cmd.extend(["--copilot-proxy-dir", run_copilot_proxy_dir]) + cmd.extend(["--copilot-proxy-port", str(int(getattr(args, "run_copilot_proxy_port", 18080)))]) if resume_session_id: cmd.extend(["--session-id", resume_session_id]) if args.run_skip_git_repo_check: @@ -1845,6 +1856,23 @@ def build_parser() -> argparse.ArgumentParser: f"If unset, child inherits Codex default model settings (available presets: {preset_names})." ), ) + parser.add_argument( + "--run-copilot-proxy", + action=argparse.BooleanOptionalAction, + default=False, + help="Route child Codex runs through a local copilot-proxy instance.", + ) + parser.add_argument( + "--run-copilot-proxy-dir", + default=None, + help=f"Path to the local copilot-proxy checkout used by child runs. {AUTO_DETECTED_PROXY_DIR_HELP}", + ) + parser.add_argument( + "--run-copilot-proxy-port", + type=int, + default=18080, + help="Local copilot-proxy port used by child runs.", + ) parser.add_argument( "--run-main-model", default=None, diff --git a/tests/test_codexloop.py b/tests/test_codexloop.py index f9c2a66..d13c0bf 100644 --- a/tests/test_codexloop.py +++ b/tests/test_codexloop.py @@ -103,6 +103,7 @@ def test_run_interactive_config_uses_passed_run_cd(monkeypatch, tmp_path: Path) monkeypatch.setattr(codexloop, "prompt_chat_id", lambda: "auto") monkeypatch.setattr(codexloop, "prompt_input", lambda prompt, default: "") monkeypatch.setattr(codexloop, "prompt_model_choice", lambda: None) + monkeypatch.setattr(codexloop, "prompt_copilot_proxy_choice", lambda preferred=False: (False, None, 18080)) monkeypatch.setattr(codexloop, "prompt_play_mode", lambda: codexloop.PLAY_MODES[1]) config = codexloop.run_interactive_config(home_dir=tmp_path / ".argusbot", run_cd=tmp_path) assert config["run_cd"] == str(tmp_path.resolve()) @@ -116,6 +117,7 @@ def test_run_interactive_config_uses_passed_run_cd(monkeypatch, tmp_path: Path) assert config["run_plan_auto_execute_delay_seconds"] == 600 assert config["run_yolo"] is True assert config["run_full_auto"] is False + assert config["run_copilot_proxy"] is False def test_run_interactive_config_supports_feishu_channel(monkeypatch, tmp_path: Path) -> None: @@ -124,6 +126,7 @@ def test_run_interactive_config_supports_feishu_channel(monkeypatch, tmp_path: P monkeypatch.setattr(codexloop, "prompt_input", lambda prompt, default: next(answers)) monkeypatch.setattr(codexloop, "prompt_secret", lambda prompt: "secret") monkeypatch.setattr(codexloop, "prompt_model_choice", lambda: None) + monkeypatch.setattr(codexloop, "prompt_copilot_proxy_choice", lambda preferred=False: (False, None, 18080)) monkeypatch.setattr(codexloop, "prompt_play_mode", lambda: codexloop.PLAY_MODES[1]) config = codexloop.run_interactive_config(home_dir=tmp_path / ".argusbot", run_cd=tmp_path) assert config["telegram_bot_token"] is None @@ -133,6 +136,26 @@ def test_run_interactive_config_supports_feishu_channel(monkeypatch, tmp_path: P assert config["feishu_chat_id"] == "oc_123" +def test_run_interactive_config_marks_copilot_preset_as_preferred(monkeypatch, tmp_path: Path) -> None: + observed: dict[str, bool] = {} + + def fake_prompt_copilot_proxy_choice(preferred=False): + observed["preferred"] = preferred + return False, None, 18080 + + monkeypatch.setattr(codexloop, "prompt_control_channel", lambda default="telegram": "telegram") + monkeypatch.setattr(codexloop, "prompt_token", lambda: "123:abc") + monkeypatch.setattr(codexloop, "prompt_chat_id", lambda: "auto") + monkeypatch.setattr(codexloop, "prompt_input", lambda prompt, default: "") + monkeypatch.setattr(codexloop, "prompt_model_choice", lambda: "copilot") + monkeypatch.setattr(codexloop, "prompt_copilot_proxy_choice", fake_prompt_copilot_proxy_choice) + monkeypatch.setattr(codexloop, "prompt_play_mode", lambda: codexloop.PLAY_MODES[1]) + + codexloop.run_interactive_config(home_dir=tmp_path / ".argusbot", run_cd=tmp_path) + + assert observed == {"preferred": True} + + def test_prompt_control_channel_default_is_telegram(monkeypatch) -> None: monkeypatch.setattr(codexloop, "prompt_input", lambda prompt, default: default) assert codexloop.prompt_control_channel() == "telegram" @@ -229,6 +252,9 @@ def test_build_daemon_command_uses_config(monkeypatch, tmp_path: Path) -> None: "follow_up_auto_execute_seconds": 900, "run_resume_last_session": True, "run_model_preset": "quality", + "run_copilot_proxy": True, + "run_copilot_proxy_dir": "/home/v-boxiuli/copilot-proxy", + "run_copilot_proxy_port": 18080, "codex_autoloop_bin": r"C:\Users\wen25\codex_loop\.venv\Scripts\python.exe -m codex_autoloop.cli", "bus_dir": str(home_dir / "bus"), "logs_dir": str(home_dir / "logs"), @@ -247,6 +273,9 @@ def test_build_daemon_command_uses_config(monkeypatch, tmp_path: Path) -> None: assert "--follow-up-auto-execute-seconds" in cmd assert "--run-check" in cmd assert "--run-model-preset" in cmd + assert "--run-copilot-proxy" in cmd + assert "--run-copilot-proxy-dir" in cmd + assert "--run-copilot-proxy-port" in cmd assert "--argusbot-bin" in cmd assert "--run-skip-git-repo-check" in cmd assert "--run-yolo" in cmd diff --git a/tests/test_copilot_proxy.py b/tests/test_copilot_proxy.py new file mode 100644 index 0000000..5ec1f58 --- /dev/null +++ b/tests/test_copilot_proxy.py @@ -0,0 +1,109 @@ +from pathlib import Path + +import pytest + +from codex_autoloop import copilot_proxy + + +def test_codex_config_overrides_include_copilot_provider() -> None: + config = copilot_proxy.CopilotProxyConfig( + enabled=True, + proxy_dir="/tmp/copilot-proxy", + port=19090, + ) + args = copilot_proxy.codex_config_overrides(config) + assert args == [ + "-c", + 'model_provider="copilot"', + "-c", + 'model_providers.copilot.name="GitHub Copilot"', + "-c", + 'model_providers.copilot.base_url="http://127.0.0.1:19090/v1"', + "-c", + 'model_providers.copilot.wire_api="responses"', + "-c", + "model_providers.copilot.requires_openai_auth=false", + ] + + +def test_resolve_proxy_dir_accepts_explicit_checkout(tmp_path: Path) -> None: + proxy_dir = tmp_path / "copilot-proxy" + proxy_dir.mkdir() + (proxy_dir / "proxy.mjs").write_text("// test", encoding="utf-8") + assert copilot_proxy.resolve_proxy_dir(str(proxy_dir)) == proxy_dir.resolve() + + +def test_resolve_proxy_dir_detects_managed_checkout(monkeypatch, tmp_path: Path) -> None: + managed_dir = tmp_path / ".argusbot" / "tools" / "copilot-proxy" + managed_dir.mkdir(parents=True) + (managed_dir / "proxy.mjs").write_text("// test", encoding="utf-8") + monkeypatch.setattr(copilot_proxy.Path, "home", lambda: tmp_path) + assert copilot_proxy.resolve_proxy_dir() == managed_dir.resolve() + + +def test_bootstrap_proxy_checkout_clones_and_runs_setup(monkeypatch, tmp_path: Path) -> None: + target_dir = tmp_path / ".argusbot" / "tools" / "copilot-proxy" + calls: list[tuple[list[str], str | None]] = [] + + def fake_run(cmd, cwd=None, check=None): + calls.append((cmd, cwd)) + if cmd[0] == "/usr/bin/git": + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "proxy.mjs").write_text("// proxy", encoding="utf-8") + (target_dir / "setup.mjs").write_text("// setup", encoding="utf-8") + return object() + + monkeypatch.setattr(copilot_proxy.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(copilot_proxy.subprocess, "run", fake_run) + + resolved = copilot_proxy.bootstrap_proxy_checkout(target_dir=target_dir) + + assert resolved == target_dir.resolve() + assert calls == [ + ( + [ + "/usr/bin/git", + "clone", + copilot_proxy.DEFAULT_COPILOT_PROXY_REPO_URL, + str(target_dir.resolve()), + ], + None, + ), + ( + [ + "/usr/bin/node", + str((target_dir / "setup.mjs").resolve()), + ], + str(target_dir.resolve()), + ), + ] + + +def test_ensure_proxy_running_starts_process_until_healthy(monkeypatch, tmp_path: Path) -> None: + proxy_dir = tmp_path / "copilot-proxy" + proxy_dir.mkdir() + (proxy_dir / "proxy.mjs").write_text("// test", encoding="utf-8") + config = copilot_proxy.CopilotProxyConfig(enabled=True, proxy_dir=str(proxy_dir), port=18080) + + health_checks = iter([False, False, True]) + started: list[list[str]] = [] + + monkeypatch.setattr(copilot_proxy, "proxy_is_healthy", lambda cfg: next(health_checks)) + monkeypatch.setattr(copilot_proxy.shutil, "which", lambda name: "/usr/bin/node") + monkeypatch.setattr(copilot_proxy.time, "sleep", lambda seconds: None) + monkeypatch.setattr( + copilot_proxy.subprocess, + "Popen", + lambda cmd, **kwargs: started.append(cmd) or object(), + ) + + copilot_proxy.ensure_proxy_running(config, startup_timeout_seconds=5) + + assert started == [["/usr/bin/node", str(proxy_dir / "proxy.mjs"), "--port", "18080"]] + + +def test_ensure_proxy_running_raises_when_proxy_dir_missing(monkeypatch) -> None: + config = copilot_proxy.CopilotProxyConfig(enabled=True, proxy_dir="/missing/proxy", port=18080) + monkeypatch.setattr(copilot_proxy, "proxy_is_healthy", lambda cfg: False) + with pytest.raises(RuntimeError): + copilot_proxy.ensure_proxy_running(config, startup_timeout_seconds=1) diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py index 9384374..ddf4e65 100644 --- a/tests/test_setup_wizard.py +++ b/tests/test_setup_wizard.py @@ -338,6 +338,64 @@ def test_build_parser_accepts_feishu_options() -> None: assert args.feishu_chat_id == "oc_123" +def test_build_parser_accepts_copilot_proxy_options() -> None: + args = setup_wizard.build_parser().parse_args( + [ + "--run-copilot-proxy", + "--run-copilot-proxy-dir", + "/home/v-boxiuli/copilot-proxy", + "--run-copilot-proxy-port", + "18080", + ] + ) + assert args.run_copilot_proxy is True + assert args.run_copilot_proxy_dir == "/home/v-boxiuli/copilot-proxy" + assert args.run_copilot_proxy_port == 18080 + + +def test_resolve_copilot_proxy_settings_bootstraps_when_explicitly_enabled(monkeypatch, tmp_path: Path) -> None: + managed_dir = tmp_path / ".argusbot" / "tools" / "copilot-proxy" + monkeypatch.setattr(setup_wizard, "resolve_proxy_dir", lambda raw=None: None) + monkeypatch.setattr(setup_wizard, "prompt_yes_no", lambda prompt, default: True) + monkeypatch.setattr(setup_wizard, "managed_proxy_dir", lambda: managed_dir) + monkeypatch.setattr(setup_wizard, "bootstrap_proxy_checkout", lambda on_progress=None: managed_dir) + + enabled, proxy_dir, port = setup_wizard.resolve_copilot_proxy_settings( + SimpleNamespace( + run_copilot_proxy=True, + run_copilot_proxy_dir=None, + run_copilot_proxy_port=18080, + run_model_preset=None, + ) + ) + + assert enabled is True + assert proxy_dir == str(managed_dir) + assert port == 18080 + + +def test_resolve_copilot_proxy_settings_bootstraps_for_copilot_preset(monkeypatch, tmp_path: Path) -> None: + managed_dir = tmp_path / ".argusbot" / "tools" / "copilot-proxy" + monkeypatch.setattr(setup_wizard, "resolve_proxy_dir", lambda raw=None: None) + monkeypatch.setattr(setup_wizard, "prompt_yes_no", lambda prompt, default: True) + monkeypatch.setattr(setup_wizard, "managed_proxy_dir", lambda: managed_dir) + monkeypatch.setattr(setup_wizard, "bootstrap_proxy_checkout", lambda on_progress=None: managed_dir) + + enabled, proxy_dir, port = setup_wizard.resolve_copilot_proxy_settings( + SimpleNamespace( + run_copilot_proxy=None, + run_copilot_proxy_dir=None, + run_copilot_proxy_port=18080, + run_model_preset=None, + ), + preferred_preset_name="copilot", + ) + + assert enabled is True + assert proxy_dir == str(managed_dir) + assert port == 18080 + + def test_resolve_feishu_config_rejects_invalid_chat_id() -> None: with pytest.raises(SystemExit) as excinfo: setup_wizard.resolve_feishu_config( diff --git a/tests/test_telegram_daemon.py b/tests/test_telegram_daemon.py index 209c384..5aabba2 100644 --- a/tests/test_telegram_daemon.py +++ b/tests/test_telegram_daemon.py @@ -221,6 +221,61 @@ def test_build_child_command_includes_feishu_args_when_configured() -> None: assert "--no-feishu-control" in cmd +def test_build_child_command_includes_copilot_proxy_args() -> None: + args = Namespace( + codex_autoloop_bin="argusbot-run", + run_max_rounds=8, + run_model_preset=None, + run_main_model="gpt-5.4", + run_main_reasoning_effort="high", + run_reviewer_model="gpt-5.4", + run_reviewer_reasoning_effort="high", + run_planner_mode="auto", + run_planner_model="gpt-5.4", + run_planner_reasoning_effort="high", + run_planner=True, + run_copilot_proxy=True, + run_copilot_proxy_dir="/home/v-boxiuli/copilot-proxy", + run_copilot_proxy_port=18080, + run_plan_update_interval_seconds=1800, + follow_up_auto_execute_seconds=3600, + telegram_bot_token="123:abc", + feishu_app_id=None, + feishu_app_secret=None, + feishu_chat_id=None, + feishu_receive_id_type="chat_id", + feishu_timeout_seconds=10, + telegram_control_whisper=True, + telegram_control_whisper_api_key=None, + telegram_control_whisper_model="whisper-1", + telegram_control_whisper_base_url="https://api.openai.com/v1", + telegram_control_whisper_timeout_seconds=90, + run_skip_git_repo_check=False, + run_full_auto=False, + run_yolo=True, + run_check=[], + run_stall_soft_idle_seconds=1200, + run_stall_hard_idle_seconds=10800, + run_state_file=".argusbot/last_state.json", + run_resume_last_session=True, + run_no_dashboard=True, + ) + cmd = build_child_command( + args=args, + objective="do work", + chat_id="42", + control_file="/tmp/control.jsonl", + operator_messages_file="/tmp/operator_messages.md", + plan_report_file="/tmp/plan.md", + plan_todo_file="/tmp/todo.md", + resume_session_id=None, + ) + assert "--copilot-proxy" in cmd + assert "--copilot-proxy-dir" in cmd + assert "/home/v-boxiuli/copilot-proxy" in cmd + assert "--copilot-proxy-port" in cmd + + def test_resolve_saved_session_id(tmp_path: Path) -> None: state_file = tmp_path / "last_state.json" state_file.write_text(json.dumps({"session_id": "thread-abc"}), encoding="utf-8")