diff --git a/.github/workflows/test-qwen-cli-provider.yml b/.github/workflows/test-qwen-cli-provider.yml new file mode 100644 index 000000000..ccf32859b --- /dev/null +++ b/.github/workflows/test-qwen-cli-provider.yml @@ -0,0 +1,94 @@ +name: Test Qwen Code Provider + +permissions: + contents: read + +on: + push: + branches: [ main, develop ] + paths: + - 'src/cli_agent_orchestrator/providers/qwen_cli.py' + - 'test/providers/test_qwen_cli_unit.py' + - 'test/providers/fixtures/qwen_cli_*.txt' + - 'pyproject.toml' + - '.github/workflows/test-qwen-cli-provider.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'src/cli_agent_orchestrator/providers/qwen_cli.py' + - 'test/providers/test_qwen_cli_unit.py' + - 'test/providers/fixtures/qwen_cli_*.txt' + - 'pyproject.toml' + - '.github/workflows/test-qwen-cli-provider.yml' + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run unit tests with coverage + run: | + uv run pytest test/providers/test_qwen_cli_unit.py \ + --cov=src/cli_agent_orchestrator/providers/qwen_cli.py \ + --cov-report=xml \ + --cov-report=term-missing \ + -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + lint: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Check code formatting with black + run: uv run black --check src/ test/ + + - name: Check import sorting with isort + run: uv run isort --check-only src/ test/ + + - name: Run type checker with mypy + run: uv run mypy src/ + continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d71dd95..5194fdef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - add Antigravity CLI (`agy`) provider — Google's terminal-native coding agent and the successor to the Gemini CLI after the free "Login with Google" path was retired (#323) +- add Qwen Code (`qwen`) provider — Alibaba's terminal-native coding agent (a Gemini-CLI fork); launches `qwen --approval-mode yolo` with `--append-system-prompt` role injection and a per-terminal `--mcp-config`, footer-anchored status detection with pyte stale-footer resolution, and OpenAI-compatible/`qwen-oauth` user-managed auth (#376) + - add built-in Hermes provider support through profile-configured `hermesProfile` wrappers - add OKF memory export/import — `cao memory export`/`cao memory import` CLI commands plus a read-scoped `GET /memory/export` API endpoint streaming a scope as a tar.gz bundle (#345) diff --git a/README.md b/README.md index 200149a2a..765e4e882 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python versions](https://img.shields.io/pypi/pyversions/cli-agent-orchestrator.svg)](https://pypi.org/project/cli-agent-orchestrator/) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/awslabs/cli-agent-orchestrator) -**CLI Agent Orchestrator (CAO)** is an open-source multi-agent orchestration framework for AI coding CLIs — Claude Code, Kiro CLI, Codex CLI, Antigravity CLI, Hermes Agent, Kimi CLI, GitHub Copilot CLI, OpenCode, and Cursor CLI. CAO runs each agent in an isolated tmux session and coordinates them with a supervisor–worker pattern over the Model Context Protocol (MCP), so one supervisor agent can delegate tasks to multiple specialist agents in parallel, sequentially, or as a swarm. +**CLI Agent Orchestrator (CAO)** is an open-source multi-agent orchestration framework for AI coding CLIs — Claude Code, Kiro CLI, Codex CLI, Antigravity CLI, Hermes Agent, Kimi CLI, GitHub Copilot CLI, OpenCode, Cursor CLI, and Qwen Code. CAO runs each agent in an isolated tmux session and coordinates them with a supervisor–worker pattern over the Model Context Protocol (MCP), so one supervisor agent can delegate tasks to multiple specialist agents in parallel, sequentially, or as a swarm. ## What is CAO? @@ -128,6 +128,7 @@ CAO drives existing CLI agent tools — it does not replace them. Before using C | **OpenCode CLI** *(experimental — temporary inbox polling fallback for multi-agent callbacks, [#203](https://github.com/awslabs/cli-agent-orchestrator/issues/203))* | [Provider docs](docs/opencode-cli.md) · [Installation](https://opencode.ai) | Per-model API key | | **Cursor CLI** | [Provider docs](docs/cursor-cli.md) · [Installation](https://cursor.com/cli) | Cursor subscription / API key | | **Antigravity CLI** | [Provider docs](docs/antigravity-cli.md) · [Installation](https://antigravity.google) | Google account (shared with the Antigravity IDE login) | +| **Qwen Code** | [Provider docs](docs/qwen-cli.md) · [Installation](https://github.com/QwenLM/qwen-code) | OpenAI-compatible API key (DashScope / gateway) or `qwen-oauth` | ## Quick Start @@ -163,7 +164,7 @@ cao launch --agents code_supervisor # Or specify a provider cao launch --agents code_supervisor --provider claude_code -# Valid: kiro_cli | claude_code | codex | antigravity_cli | hermes | kimi_cli | copilot_cli | opencode_cli | cursor_cli +# Valid: kiro_cli | claude_code | codex | antigravity_cli | hermes | kimi_cli | copilot_cli | opencode_cli | cursor_cli | qwen_cli # Unrestricted access, skip confirmation (DANGEROUS) cao launch --agents code_supervisor --yolo @@ -273,7 +274,7 @@ provider: claude_code --- ``` -Valid values: `kiro_cli`, `claude_code`, `codex`, `antigravity_cli`, `hermes`, `kimi_cli`, `copilot_cli`, `opencode_cli`, `cursor_cli`. The `cao launch --provider` flag always takes precedence for the initial session. See [`examples/cross-provider/`](examples/cross-provider/). +Valid values: `kiro_cli`, `claude_code`, `codex`, `antigravity_cli`, `hermes`, `kimi_cli`, `copilot_cli`, `opencode_cli`, `cursor_cli`, `qwen_cli`. The `cao launch --provider` flag always takes precedence for the initial session. See [`examples/cross-provider/`](examples/cross-provider/). ### Tool Restrictions diff --git a/docs/issues/376-qwen-cli-provider/design.md b/docs/issues/376-qwen-cli-provider/design.md new file mode 100644 index 000000000..bac0ca21f --- /dev/null +++ b/docs/issues/376-qwen-cli-provider/design.md @@ -0,0 +1,197 @@ +# Qwen Code (`qwen_cli`) Provider — Design + +Issue: [#376](https://github.com/awslabs/cli-agent-orchestrator/issues/376) — *[Feat] Qwen Code (qwen) provider adapter* +Date: 2026-07-09 +Status: Approved (design), implementation in progress + +## 1. Summary + +Add [Qwen Code](https://github.com/QwenLM/qwen-code) (`qwen`) as a first-class CAO +provider. `qwen` is an interactive, full-screen Ink TUI coding agent — a fork of +Google's `gemini-cli`. The adapter launches `qwen` inside a tmux window, detects +its status from terminal output, extracts the last response, and cleans up. It +integrates with CAO's assign/handoff/send_message orchestration exactly like the +existing providers. + +Authentication is **user-managed**: `qwen` reads OpenAI-compatible credentials +from the ambient environment (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`, +DashScope-compatible endpoints) or `qwen-oauth`. The provider never handles +credentials. + +## 2. Why `antigravity_cli` is the template + +`qwen` and Google's `agy` (Antigravity CLI, `antigravity_cli.py`) are **both +gemini-cli-derived Ink TUIs**. They share the same status-surface shape and the +same class of bug, so the sibling provider is a far closer template than the +Kimi/Cursor providers named in the issue: + +- **Footer-anchored status.** Idle footer shows `? for shortcuts`; the busy + spinner line contains `esc to cancel`. Identical to `agy`. +- **In-place footer redraw → stale-buffer latch.** The raw pipe-pane byte stream + keeps a stale `esc to cancel` forever after a turn ends. `antigravity_cli` + solves this with `supports_screen_detection = True` + a pyte-rendered + `get_status_from_screen()`. `qwen` needs the same fix. +- **`mcpServers`-shaped MCP config** and a `--model` flag map directly. + +Startup-dialog handling borrows the polling-loop shape from `kimi_cli` +(`_handle_startup_dialog`). + +> Note: the `skills/cao-provider/references/provider-template.md` template +> reflects an **older** provider contract (`get_status(tail_lines)` + +> `tmux_client.get_history`). The current `base.py` contract is +> `async initialize()` + `get_status(self, buffer)` (raw pipe-pane stream, with +> `_resolve_native_status()` called first). This design follows the real, +> current providers, not the stale template. + +## 3. Relevant `qwen` v0.19.5 facts (verified against the installed binary) + +| Concern | Fact | +|---|---| +| Auto-approve | `--approval-mode yolo` (footer: `YOLO mode (shift + tab to cycle)`). `-y/--yolo` equivalent. | +| Model | `-m, --model `. | +| System prompt | `--append-system-prompt ""` appends to qwen's built-in agent prompt (works in interactive mode). `--system-prompt` fully overrides. | +| MCP | `--mcp-config ` accepts a **file path** to `{"mcpServers": {...}}`. Per-server `env` is honored. Also reads `~/.qwen/settings.json` / `.qwen/settings.json`. | +| Context file | `QWEN.md` (the `GEMINI.md` equivalent). | +| Auth (OpenAI-compat) | env `OPENAI_API_KEY` + `OPENAI_BASE_URL` + `OPENAI_MODEL` (alias `QWEN_MODEL`); all three present ⇒ auto-selects `openai` auth, skips the auth dialog. `qwen-oauth` is browser-only (not headless). | +| Idle signal | footer `? for shortcuts`; input placeholder `Type your message or @path/to/file`; no `esc to cancel`. | +| Busy signal | spinner line containing `esc to cancel` (with elapsed-seconds timer). | +| Response delimiting | user line prefixed `>`; assistant/tool lines prefixed `●`; errors prefixed `✕ [API Error: …]`. | +| Exit | `/quit`. | + +## 4. Design decisions (aligned with the maintainer) + +1. **PR scope: full package** — provider + all wiring + unit tests & fixtures + + e2e test classes + docs, matching issue #376's acceptance criteria. +2. **MCP via per-instance `--mcp-config` temp file** — each terminal writes its + own `{"mcpServers": {...}}` JSON (with `CAO_TERMINAL_ID` injected into each + server's `env`) and points `--mcp-config` at it. No shared-file mutation, so + no concurrency/cleanup hazard (avoids lesson #19). The temp file is tracked in + `_tmp_paths` and deleted in `cleanup()`. +3. **System prompt via `--append-system-prompt`** — compose + `profile.system_prompt` → `_apply_skill_prompt()` → (when restricted) + `SECURITY_PROMPT` + allowed-tool list, and append it on top of qwen's built-in + agent prompt. Preserves qwen's native coding/tool scaffolding. +4. **Soft tool enforcement** — like the sibling gemini-fork provider (and + Kimi/Codex), restrictions are advisory text in the system prompt. `qwen_cli` + joins `SOFT_ENFORCEMENT_PROVIDERS`. Documented as a Known Limitation. A + `tool_mapping.py` entry (gemini-style native names) is added only for the + launch-confirmation summary. + +## 5. Provider implementation (`src/cli_agent_orchestrator/providers/qwen_cli.py`) + +### Module-level patterns (calibrated to qwen's footer TUI) + +- `ANSI` stripping — via `strip_terminal_escapes` from `utils/text.py`. +- `PROCESSING_FOOTER_PATTERN = r"esc to cancel"` — busy spinner line. +- `IDLE_FOOTER_PATTERN = r"\?\s*for shortcuts"` (+ `_LOG` variant for the file + watcher). +- `QUERY_PROMPT_PATTERN = r"^\s*>\s+\S"` — user turn line (extraction boundary). +- `RESPONSE_BULLET_PATTERN = r"^\s*●\s"` — assistant/tool line. +- `SEPARATOR_PATTERN` — the full-width `─` rules bounding the input box. +- `WAITING_USER_ANSWER_PATTERN` — trust/confirmation dialogs (defensive; yolo + suppresses most). +- `ERROR_PATTERN = r"✕\s*\[API Error"` — surfaced turn error. +- `FOOTER_TAIL_WINDOW = 2048`. + +### Class `QwenCliProvider(BaseProvider)` + +- `__init__(terminal_id, session_name, window_name, agent_profile=None, + allowed_tools=None, model=None, skill_prompt=None)`; calls + `super().__init__(terminal_id, session_name, window_name, allowed_tools, + skill_prompt)`. Fields: `_agent_profile`, `_model`, `_initialized=False`, + `_turns=0`, `_tmp_paths: list[Path]`. +- `paste_enter_count = 1` (Ink single-Enter submit; validate live). +- `_build_qwen_command()`: + - `shutil.which("qwen")` guard → `ProviderError` if missing. + - `qwen --approval-mode yolo`. + - `--model` from `profile.model` (preferred) or `self._model`. + - `--append-system-prompt ""` (only when composed text is non-empty). + - `--mcp-config ` when `profile.mcpServers` is present; inject + `CAO_TERMINAL_ID` into each server `env`; record path in `_tmp_paths`. +- `initialize()` (async): `wait_for_shell` → `status_monitor.notify_input_sent` + → `send_keys(command)` → `_handle_startup_dialog()` (poll-dismiss first-run + theme/trust splash; early-return once footer is ready) → + `wait_until_status({IDLE, COMPLETED}, timeout≈180s)`. +- `get_status(buffer)`: `_resolve_native_status()` first; empty → `UNKNOWN`; + `strip_terminal_escapes`; `tail = clean[-FOOTER_TAIL_WINDOW:]`. Priority: + WAITING_USER_ANSWER → PROCESSING (`esc to cancel` in tail) → COMPLETED/IDLE + (`? for shortcuts` and no `esc to cancel`; `_turns` splits COMPLETED vs IDLE) → + ERROR (`✕ [API Error`) → UNKNOWN. +- `supports_screen_detection = True` + `get_status_from_screen(screen_lines)` + mirroring the same precedence on the pyte-rendered frame (the stale-footer fix). +- `extract_last_message_from_script(script_output)`: text between the last + `> ` line and the next separator/footer, chrome-filtered, escapes + stripped. +- `exit_cli() -> "/quit"`. +- `cleanup()`: `_initialized=False`; delete each `_tmp_paths` entry (swallow + errors). +- `mark_input_received()`: `super().mark_input_received()` then `_turns += 1`. + +## 6. Wiring (6 files) + +| File | Change | +|---|---| +| `models/provider.py` | `QWEN_CLI = "qwen_cli"` | +| `providers/manager.py` | `elif provider_type == ProviderType.QWEN_CLI.value:` → `QwenCliProvider(..., model=model, skill_prompt=skill_prompt)` | +| `providers/__init__` / import in manager | import `QwenCliProvider` | +| `cli/commands/launch.py` | add `"qwen_cli"` to `PROVIDERS_REQUIRING_WORKSPACE_ACCESS` | +| `services/terminal_service.py` | add `ProviderType.QWEN_CLI.value` to `RUNTIME_SKILL_PROMPT_PROVIDERS` and `SOFT_ENFORCEMENT_PROVIDERS` | +| `utils/tool_mapping.py` | add `"qwen_cli"` block (gemini-style native tool names, display-only) | + +`model` already flows automatically: `terminal_service.py` passes +`model=profile.model` into `create_provider`. + +## 7. Testing + +### Unit — `test/providers/test_qwen_cli_unit.py` (no network required) +- Initialization: success, shell timeout, CLI timeout, startup-dialog dismissal. +- Status: IDLE, PROCESSING, COMPLETED, WAITING_USER_ANSWER, ERROR, empty; + **COMPLETED-over-PROCESSING** stale-buffer case; `get_status_from_screen` path. +- Extraction: success, no marker, empty, multiple turns (last wins), ANSI strip. +- Regex patterns matched against fixture text. +- Command building: `--approval-mode yolo`, `--model`, `--append-system-prompt` + composition, `--mcp-config` file written with `CAO_TERMINAL_ID` injected, + soft `SECURITY_PROMPT` appended when restricted, missing-binary → `ProviderError`. +- Edge: `exit_cli`, `cleanup` deletes `_tmp_paths`, `paste_enter_count`. + +### Fixtures — `test/providers/fixtures/qwen_cli_*.txt` +`idle`, `processing`, `completed`, `response`, `waiting`. Captured from the real +`qwen` TUI in tmux (`tmux capture-pane -e -p`). + +### E2E — add `TestQwenCli*` classes to the 5 e2e files + `require_qwen_cli` +fixture in `test/e2e/conftest.py` (mirroring the Kimi/Cursor classes): handoff +(2), assign (3), send_message (1), allowed_tools (3, restricted-cannot-bash +marked `xfail` for soft enforcement), supervisor_orchestration (2). + +### Validation caveat (network) +The provided test endpoint is an Alibaba Cloud MaaS region +(`cn-beijing.maas.aliyuncs.com`) that is **unreachable from the CI/build +environment** (connection times out). Therefore: +- Unit tests and **idle / startup / processing / error-turn** fixtures are + captured locally (qwen renders TUI chrome even when the API call times out). +- A **successful `●` response** fixture and the full live e2e suite require a + reachable OpenAI-compatible endpoint. Per the issue, the author will calibrate + status detection against real `qwen` TUI output on a reachable endpoint. The + provider's status/extraction logic keys on TUI **chrome**, not response + content, so the local fixtures exercise every `get_status` branch. + +## 8. Documentation +- `docs/qwen-cli.md` (mirror `docs/antigravity-cli.md`): prerequisites, auth, + launch examples, agent-profile format, MCP config, Known Limitations (soft + tool enforcement; headless auth constraints), troubleshooting. +- `README.md`: provider table row + the four inline provider enumerations + (intro line 7, frontmatter comment ~166, cross-provider list ~276). +- `CHANGELOG.md`: new-provider entry. + +## 9. File checklist +- [ ] `src/cli_agent_orchestrator/models/provider.py` +- [ ] `src/cli_agent_orchestrator/providers/qwen_cli.py` +- [ ] `src/cli_agent_orchestrator/providers/manager.py` +- [ ] `src/cli_agent_orchestrator/cli/commands/launch.py` +- [ ] `src/cli_agent_orchestrator/services/terminal_service.py` +- [ ] `src/cli_agent_orchestrator/utils/tool_mapping.py` +- [ ] `test/providers/test_qwen_cli_unit.py` +- [ ] `test/providers/fixtures/qwen_cli_*.txt` +- [ ] `test/e2e/conftest.py` (+ `TestQwenCli*` in 5 e2e files) +- [ ] `docs/qwen-cli.md` +- [ ] `README.md`, `CHANGELOG.md` diff --git a/docs/qwen-cli.md b/docs/qwen-cli.md new file mode 100644 index 000000000..89f04927a --- /dev/null +++ b/docs/qwen-cli.md @@ -0,0 +1,124 @@ +# Qwen Code Provider + +## Overview + +The Qwen Code provider enables CAO to work with [Qwen Code](https://github.com/QwenLM/qwen-code) (`qwen`), Alibaba's terminal-native AI coding agent — a fork of Google's Gemini CLI. `qwen` runs as an interactive full-screen Ink TUI that keeps scrollback history in tmux. + +## Prerequisites + +- **Qwen Code**: Install via `npm install -g @qwen-code/qwen-code` (Node.js 20+). +- **Authentication** (inherited from Qwen Code — no CAO-specific setup): CAO does **not** manage qwen credentials. Exactly like the `claude_code` provider inherits your `claude` login, `qwen_cli` inherits qwen's own OpenAI-compatible credentials. Configure `qwen` **once** — the same way you would to use it standalone — and every CAO-spawned qwen worker picks it up automatically (no `export` before `cao-server`, no `--env`). + + **Simplest path — a one-time `~/.qwen/.env` file.** `qwen` loads it natively (bundled `dotenv`): + + ```bash + # ~/.qwen/.env (chmod 600 — it holds your API key) + OPENAI_API_KEY=sk-... + OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # your region — see table below + ``` + + That is the whole setup. `OPENAI_MODEL` is **optional** — set the model in the agent profile (`model: qwen3-coder-plus`) instead, so the only secret you ever plug in is the **API key**. (`qwen` also reads these from `~/.env`, a project-level `.qwen/.env`, or `~/.qwen/settings.json` → `security.auth.apiKey`; a value already present in the process environment always wins over the file, which is what makes the overrides below work.) + + When an API key + base URL are present, `qwen` auto-selects `openai` auth and skips the interactive auth dialog. (`qwen-oauth` browser login also works interactively but **cannot complete headless/in-container**, so it is unsuitable for CAO-spawned sessions.) + + **Choose your region / endpoint.** The endpoint *is* the `OPENAI_BASE_URL`, so switching region is just switching that one value — pick the region your account is provisioned for. + + *Shared DashScope endpoints* (simplest — no workspace needed): + + | Endpoint | `OPENAI_BASE_URL` | + |----------|-------------------| + | Mainland China (Beijing) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | + | International (Singapore) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | + + *Model Studio (百炼) workspace gateway* — a per-workspace host of the form `https://..maas.aliyuncs.com/compatible-mode/v1`. Copy the exact host from your Model Studio console; the region code is one of: + + | Region | Region code | + |--------|-------------| + | North China (Beijing) | `cn-beijing` | + | Singapore | `ap-southeast-1` | + | US (Virginia) | `us-east-1` | + | Germany (Frankfurt) | `eu-central-1` | + | Japan (Tokyo) | `ap-northeast-1` | + + Any other OpenAI-compatible `/v1` endpoint (custom / self-hosted gateway) works too. The model ids in [Quick Start](#quick-start) must be ones your chosen endpoint exposes. + + **Overrides** (optional — take precedence over `~/.qwen/.env`; handy for CI or targeting a different region per session): + - Per session, forwarded to the supervisor **and** every handoff-spawned worker: `cao launch … --env OPENAI_API_KEY=… --env OPENAI_BASE_URL=…` (values travel in the request body, not the URL). + - Or process-wide: `export OPENAI_API_KEY=… OPENAI_BASE_URL=…` before starting `cao-server`. +- **tmux 3.2+** + +Verify installation: + +```bash +qwen --version +``` + +## Backend Support + +The provider currently requires the **tmux** backend (`cao-server --terminal tmux`, the default). It opts into screen-based status detection (`supports_screen_detection = True`), which is driven by the FIFO / pyte pipeline that tmux provides. + +The **herdr** backend is **not yet supported**: herdr uses an event inbox and skips the FIFO pipeline for providers it has no native status integration for, so `qwen`'s state is never observed and terminals time out. Generic herdr FIFO support for non-native providers is tracked as a follow-up; until then, run `qwen_cli` on the tmux backend. + +## Quick Start + +```bash +# Launch with CAO +cao launch --agents developer --provider qwen_cli +``` + +Set a model in the agent profile (`model:` field) or via `OPENAI_MODEL`. Model names are the ids your endpoint exposes, e.g. `qwen3-coder-plus`, `qwen3-max`. + +## Launch Command + +``` +qwen --approval-mode yolo [--model ""] [--append-system-prompt ""] [--mcp-config ""] +``` + +- `--approval-mode yolo` auto-approves tool calls so orchestrated (handoff / assign) flows do not block on per-tool approval prompts. +- `--model` selects the model (profile `model:` wins over a constructor override); omit it to use `OPENAI_MODEL`. +- `--append-system-prompt` layers the agent profile's system prompt (+ skill catalog, + security prompt when tool-restricted) on top of qwen's built-in agent prompt. Unlike Gemini CLI's `-i`, this is a true system prompt, so no "acknowledge and wait" guard is needed. +- `--mcp-config` points at a per-terminal MCP config file (see [MCP Servers](#mcp-servers)). + +## Status Detection + +The provider classifies `qwen` states from the tmux output buffer (footer-anchored, render-stable): + +| Status | Pattern | Description | +|--------|---------|-------------| +| **PROCESSING** | Spinner line `esc to cancel` (with an elapsed timer) | Response streaming or tool executing | +| **IDLE** | Ready input box (`YOLO mode (shift + tab to cycle)` / `Type your message or @…` / `? for shortcuts`), no turn delivered yet | Ready for first input | +| **COMPLETED** | Ready input box, ≥1 turn delivered | Turn finished (a transient `✕ [API Error …]` turn also returns here — it is retryable, not a fatal state) | +| **WAITING_USER_ANSWER** | Approval / picker prompt (`Allow execution`, `Do you want to proceed`, `[y/n]`, …) | Blocked on user input | +| **ERROR** | `Error:`, `panic:`, `Traceback` patterns | Hard error (crashed binary) | + +The TUI is identical for IDLE and COMPLETED, so the two are split on an internal turn counter (`mark_input_received`), exactly as the Antigravity CLI provider does. This preserves the "wait for IDLE before delivering the task" contract right after initialization. + +Because `qwen` redraws the spinner/footer in place, the raw append-only pipe-pane stream keeps a stale `esc to cancel` after a turn ends. The provider therefore opts into pyte rendered-screen detection (`supports_screen_detection = True` / `get_status_from_screen`), which resolves the in-place redraw so only the live frame is classified. + +> **`qwen_cli` requires pyte detection.** Without it the stale spinner pins the terminal to PROCESSING forever, so a completed turn is never detected and orchestrated `handoff` / `assign` calls block until they time out. Pyte detection is **on by default** (`CAO_PYTE_STATUS`, which accepts `1` / `true` / `yes` / `on`); set `CAO_PYTE_STATUS=false` (or `0`) only to deliberately fall back to the raw-stream path. Set the value in the environment where you start `cao-server`. + +### TUI Structure + +``` +> + ● (filled bullet marks assistant/tool output) +──────────────────────────────── (input box top rule, full-width U+2500) +* Type your message or @path/to/file +──────────────────────────────── (input box bottom rule) + ➜ · + YOLO mode (shift + tab to cycle) +``` + +Response extraction returns the text between the last echoed `> ` line and the next full-width separator, stripping the assistant bullet (`●`) and filtering TUI chrome (banner, separators, footer, tips, spinner). + +## MCP Servers + +The provider writes a **per-terminal** MCP config file (`{"mcpServers": {...}}`) to a temp path and passes it via `--mcp-config`. It forwards `CAO_TERMINAL_ID` into each server's `env` so `cao-mcp-server` can resolve the current terminal for handoff / assign. The file is removed on `cleanup()`. A per-instance file (rather than mutating the shared `~/.qwen/settings.json`) avoids cross-terminal write races when many terminals launch concurrently. + +## Tool Restrictions + +`qwen_cli` is in `SOFT_ENFORCEMENT_PROVIDERS`: tool restrictions are advisory. Under `--approval-mode yolo` there is no native CAO tool denylist, so when a profile is not allowed every tool (e.g. the read-only reviewer), the security prompt is appended to the injected system prompt. There is no native hard-block flag. + +## Exit + +`qwen` exits on the `/quit` slash command. diff --git a/src/cli_agent_orchestrator/api/main.py b/src/cli_agent_orchestrator/api/main.py index 55266adf4..55d930695 100644 --- a/src/cli_agent_orchestrator/api/main.py +++ b/src/cli_agent_orchestrator/api/main.py @@ -820,6 +820,7 @@ async def list_providers_endpoint() -> List[Dict]: "opencode_cli": "opencode", "cursor_cli": "agent", "antigravity_cli": "agy", + "qwen_cli": "qwen", } result = [] for provider, binary in provider_binaries.items(): diff --git a/src/cli_agent_orchestrator/cli/commands/launch.py b/src/cli_agent_orchestrator/cli/commands/launch.py index 93ed4c02f..b77e9854c 100644 --- a/src/cli_agent_orchestrator/cli/commands/launch.py +++ b/src/cli_agent_orchestrator/cli/commands/launch.py @@ -33,6 +33,7 @@ "kimi_cli", "kiro_cli", "opencode_cli", + "qwen_cli", } # Validation constraints for ``--env`` forwarded vars (mirrored server-side diff --git a/src/cli_agent_orchestrator/constants.py b/src/cli_agent_orchestrator/constants.py index ecdab56d5..1d3f62847 100644 --- a/src/cli_agent_orchestrator/constants.py +++ b/src/cli_agent_orchestrator/constants.py @@ -22,6 +22,34 @@ def _env_int(name: str, default: int) -> int: return default +# Recognized spellings for env_bool(). Kept small and conventional so an +# operator can write whichever truthy/falsy form is habitual. +_ENV_TRUE = frozenset({"1", "true", "yes", "on"}) +_ENV_FALSE = frozenset({"0", "false", "no", "off"}) + + +def env_bool(name: str, default: bool = False) -> bool: + """Read a boolean env var, tolerating the usual truthy/falsy spellings. + + Accepts ``1/true/yes/on`` (True) and ``0/false/no/off`` (False), + case-insensitively and whitespace-tolerant. Unset, empty, or unrecognized + values fall back to ``default`` rather than silently flipping the flag. + + This replaces the ``os.environ.get(name, ...).lower() == "true"`` idiom, + which treated the common ``CAO_PYTE_STATUS=1`` as False and silently + disabled the pyte screen-detector that some providers require. + """ + raw = os.environ.get(name) + if raw is None: + return default + token = raw.strip().lower() + if token in _ENV_TRUE: + return True + if token in _ENV_FALSE: + return False + return default + + # ============================================================================= # Session Configuration # ============================================================================= @@ -91,9 +119,13 @@ def _env_int(name: str, default: int) -> int: # handoff, send_message, skills, supervisor orchestration — every test green; # the only failures traced to network outages and a slow uvx MCP launch path, # not detection). Only providers that opt in via supports_screen_detection -# (claude_code, kimi_cli) use it; all others and the herdr backend are -# unaffected. Set CAO_PYTE_STATUS=false to fall back to the raw-stream path. -CAO_PYTE_STATUS = os.environ.get("CAO_PYTE_STATUS", "true").lower() == "true" +# (claude_code, kimi_cli, qwen_cli, antigravity_cli) use it; all others and the +# herdr backend are unaffected. Set CAO_PYTE_STATUS=false to fall back to the +# raw-stream path. qwen_cli/antigravity_cli REQUIRE this (their raw pipe-pane +# retains a stale "esc to cancel" → false PROCESSING), so a truthy value must +# actually enable it — hence env_bool(), which accepts 1/yes/on/true, not just +# the literal "true". +CAO_PYTE_STATUS = env_bool("CAO_PYTE_STATUS", default=True) # pyte screen geometry — mirror the tmux pane size (clients/tmux.py x=220 y=50) # so the rendered viewport matches what the agent's TUI actually drew. @@ -112,7 +144,7 @@ def _env_int(name: str, default: int) -> int: # PROCESSING state for providers that declare # accepts_input_while_processing=True. Eliminates latency between agent turns # for capable providers (e.g., Claude Code). -EAGER_INBOX_DELIVERY = os.environ.get("CAO_EAGER_INBOX_DELIVERY", "false").lower() == "true" +EAGER_INBOX_DELIVERY = env_bool("CAO_EAGER_INBOX_DELIVERY", default=False) # Poll interval (seconds) for the OpenCode inbox poller. OpenCode buffers input # and its pipe-pane output can stop changing once the TUI settles, so the diff --git a/src/cli_agent_orchestrator/mcp_server/server.py b/src/cli_agent_orchestrator/mcp_server/server.py index e68d3ab89..6ffc9580c 100644 --- a/src/cli_agent_orchestrator/mcp_server/server.py +++ b/src/cli_agent_orchestrator/mcp_server/server.py @@ -14,6 +14,7 @@ API_BASE_URL, DEFAULT_PROVIDER, WORKFLOW_RUN_REQUEST_TIMEOUT, + env_bool, ) from cli_agent_orchestrator.mcp_server.models import HandoffResult from cli_agent_orchestrator.models.inbox import OrchestrationType @@ -36,12 +37,12 @@ def _mcp_timeout() -> float: # Environment variable to enable/disable working_directory parameter -ENABLE_WORKING_DIRECTORY = os.getenv("CAO_ENABLE_WORKING_DIRECTORY", "false").lower() == "true" +ENABLE_WORKING_DIRECTORY = env_bool("CAO_ENABLE_WORKING_DIRECTORY", default=False) # Environment variable to enable/disable automatic sender terminal ID injection. # Defaults to enabled (issue #284): callback routing must not depend on the # supervisor LLM remembering to hand-write its terminal ID into the message. -ENABLE_SENDER_ID_INJECTION = os.getenv("CAO_ENABLE_SENDER_ID_INJECTION", "true").lower() == "true" +ENABLE_SENDER_ID_INJECTION = env_bool("CAO_ENABLE_SENDER_ID_INJECTION", default=True) # Terminal count threshold for cleanup nudge TERMINAL_CLEANUP_NUDGE_THRESHOLD = 10 diff --git a/src/cli_agent_orchestrator/models/provider.py b/src/cli_agent_orchestrator/models/provider.py index c901b6e33..b758bcaa0 100644 --- a/src/cli_agent_orchestrator/models/provider.py +++ b/src/cli_agent_orchestrator/models/provider.py @@ -13,3 +13,4 @@ class ProviderType(str, Enum): HERMES = "hermes" CURSOR_CLI = "cursor_cli" ANTIGRAVITY_CLI = "antigravity_cli" + QWEN_CLI = "qwen_cli" diff --git a/src/cli_agent_orchestrator/providers/manager.py b/src/cli_agent_orchestrator/providers/manager.py index b14d66cf3..17ca97d23 100644 --- a/src/cli_agent_orchestrator/providers/manager.py +++ b/src/cli_agent_orchestrator/providers/manager.py @@ -15,6 +15,7 @@ from cli_agent_orchestrator.providers.kimi_cli import KimiCliProvider from cli_agent_orchestrator.providers.kiro_cli import KiroCliProvider from cli_agent_orchestrator.providers.opencode_cli import OpenCodeCliProvider +from cli_agent_orchestrator.providers.qwen_cli import QwenCliProvider logger = logging.getLogger(__name__) @@ -123,6 +124,16 @@ def create_provider( model=model, skill_prompt=skill_prompt, ) + elif provider_type == ProviderType.QWEN_CLI.value: + provider = QwenCliProvider( + terminal_id, + tmux_session, + tmux_window, + agent_profile, + allowed_tools, + model=model, + skill_prompt=skill_prompt, + ) else: raise ValueError(f"Unknown provider type: {provider_type}") diff --git a/src/cli_agent_orchestrator/providers/qwen_cli.py b/src/cli_agent_orchestrator/providers/qwen_cli.py new file mode 100644 index 000000000..216dbbf41 --- /dev/null +++ b/src/cli_agent_orchestrator/providers/qwen_cli.py @@ -0,0 +1,619 @@ +"""Qwen Code (``qwen``) provider implementation. + +Qwen Code (https://github.com/QwenLM/qwen-code) is Alibaba's terminal-native AI +coding agent — a fork of Google's Gemini CLI. The CLI is invoked via the +``qwen`` binary (``npm install -g @qwen-code/qwen-code``). + +Key characteristics (observed on ``qwen`` 0.19.x, full-screen Ink TUI): + +- Command: ``qwen --approval-mode yolo`` to auto-approve tool calls so + orchestrated (handoff / assign) flows do not block on per-tool approval + prompts, ``--model ""`` to pick a model, ``--append-system-prompt + ""`` to layer the agent profile's role/instructions on top of qwen's + built-in agent prompt, and ``--mcp-config `` to point at a per-terminal + MCP server config. +- Idle prompt: an input box delimited by full-width ``─`` (U+2500) rule lines + with the placeholder ``Type your message or @path/to/file``, and a footer + showing the approval mode (``YOLO mode (shift + tab to cycle)``) and the + model name (``➜ · ``). +- Processing: a spinner line containing ``esc to cancel`` (with an elapsed + timer, e.g. ``.. Figuring out ... (16.5s · esc to cancel)``) is rendered + above the input box. The ``esc to cancel`` marker is the reliable, + render-stable signal (it survives ``strip_terminal_escapes``); the witty + spinner phrase is not. +- Completed: assistant / tool output is rendered with a filled bullet + (``● ``) between the echoed ``> `` line and the input box, and + the spinner disappears. +- MCP config: a per-terminal JSON file (``{"mcpServers": {...}}``) written to a + temp path and passed via ``--mcp-config``. ``CAO_TERMINAL_ID`` is injected + into each server's ``env`` so cao-mcp-server can resolve the terminal. Using a + per-instance file (rather than mutating the shared ``~/.qwen/settings.json``) + avoids cross-terminal write races. +- Auth: user-managed. ``qwen`` reads OpenAI-compatible credentials from the + ambient environment (``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` / + ``OPENAI_MODEL``, DashScope-compatible endpoints) or ``qwen-oauth``; the + provider never handles credentials. +- Exit: ``/quit`` (slash command). + +Status detection mirrors the footer-anchored approach of the sibling +Antigravity CLI provider (both are Gemini-CLI-derived Ink TUIs): the presence of +``esc to cancel`` means PROCESSING; a ready input box (approval-mode footer / +placeholder) means IDLE / COMPLETED (split on a turn counter, since the TUI +looks identical in both states). Because the footer redraws in place, the raw +pipe-pane stream keeps a stale ``esc to cancel`` after a turn ends, so the +provider opts in to pyte rendered-screen detection (see +``supports_screen_detection`` / ``get_status_from_screen``). +""" + +import json +import logging +import os +import re +import shlex +import shutil +import tempfile +import time +from pathlib import Path +from typing import List, Optional + +from cli_agent_orchestrator.backends.registry import get_backend +from cli_agent_orchestrator.constants import SECURITY_PROMPT +from cli_agent_orchestrator.models.terminal import TerminalStatus +from cli_agent_orchestrator.providers.base import BaseProvider +from cli_agent_orchestrator.services.settings_service import get_server_settings +from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status +from cli_agent_orchestrator.utils.text import strip_terminal_escapes + +logger = logging.getLogger(__name__) + + +class ProviderError(Exception): + """Exception raised for Qwen Code provider-specific errors.""" + + pass + + +# ============================================================================= +# Regex patterns for Qwen Code (qwen) output analysis +# ============================================================================= + +# PROCESSING signal. ``qwen`` renders "esc to cancel" on the spinner line every +# frame the agent is working on a turn; it disappears once the turn completes. +# This is the reliable, render-stable processing marker (it survives +# ``strip_terminal_escapes``); the witty spinner phrase before it varies. +PROCESSING_FOOTER_PATTERN = r"esc to cancel" + +# IDLE / COMPLETED signal: the ready input box. qwen always renders the +# approval-mode footer ("... (shift + tab to cycle)") and the empty-input +# placeholder ("Type your message or @path/to/file") whenever it is ready for +# input; older/newer builds may also show "? for shortcuts". Any of these means +# the input box is present (PROCESSING is checked first, so a live spinner is +# already excluded by the time this runs). +IDLE_FOOTER_PATTERN = r"(?:shift \+ tab to cycle|Type your message or @|\?\s*for shortcuts)" +# Same hint for log-file pre-checks (no ANSI involved). +IDLE_FOOTER_PATTERN_LOG = IDLE_FOOTER_PATTERN + +# Spinner line: a leading glyph (dots / braille) followed by a witty phrase and +# the "(Ns · esc to cancel)" timer. Secondary processing cue; the phrase varies +# so we anchor on the timer + cancel hint. +PROCESSING_SPINNER_PATTERN = r"\(\s*[\d.]+s\s*[·.]\s*esc to cancel\s*\)" + +# Echoed user query line: "> " (non-empty after the prompt char). +# Start-of-line anchored so it does not match an empty prompt. +QUERY_PROMPT_PATTERN = r"^\s*>\s+\S" + +# Assistant / tool output bullet ("● "). In qwen this marks response +# content (unlike a plain chrome glyph) — the leading bullet is stripped during +# extraction and the text kept. +RESPONSE_BULLET_PATTERN = r"^\s*●\s?" + +# Full-width horizontal rule (U+2500) delimiting the input box / transcript +# sections. Anchored to a full line; tolerates surrounding whitespace. +SEPARATOR_PATTERN = r"^\s*─{20,}\s*$" + +# Interactive prompts that block on user input (tool-approval dialogs, pickers). +# With --approval-mode yolo these are rare, but we still classify them as +# WAITING_USER_ANSWER so orchestrated input is not mistaken for the answer to +# such a prompt. Phrasing verified against the qwen binary. +WAITING_USER_ANSWER_PATTERN = ( + r"(?:Allow execution)" + r"|(?:Apply this change)" + r"|(?:Do you want to proceed)" + r"|(?:Yes, allow (?:once|always))" + r"|(?:Waiting for user confirmation)" + r"|(?:↑/↓\s*(?:to )?[Nn]avigate)" + r"|(?:\[\s*y\s*/\s*n\s*\])" +) + +# First-run dialogs qwen may show before the input box is ready (theme picker, +# folder-trust gate). Dismissed by accepting the pre-selected option with Enter +# in _handle_startup_dialog(). With OpenAI-compatible credentials configured qwen +# skips the auth dialog, so this is typically a no-op. +STARTUP_DIALOG_PATTERN = ( + r"(?:Select Theme|Choose.*theme|How would you like to (?:theme|authenticate))" + r"|(?:trust (?:this )?folder|Do you trust the files in this folder)" + r"|(?:Get started|Sign in with)" +) + +# Hard-error patterns surfaced by a crashed binary. A transient "✕ [API Error +# ...]" turn is NOT treated as a status error: qwen returns to the ready input +# box (retryable), so it is reported as COMPLETED and the error text is +# extractable as the turn outcome. +ERROR_PATTERN = ( + r"^(?:Error:|ERROR:|panic:|qwen: .*(?:error|failed)|Traceback \(most recent call last\):)" +) + +# Tail window (chars) scanned for the footer markers. The footer + spinner are +# rendered in the last few hundred bytes of every TUI frame; 2KB is well within +# the StatusMonitor's rolling buffer and avoids flipping to IDLE mid-response +# when a long answer scrolls the older spinner out of the window. +FOOTER_TAIL_WINDOW = 2048 + +# Chrome lines filtered out of the extracted response. +_BANNER_PATTERN = r"(?:Qwen Code \(?v?\d|▀|▄|█|╚|╝|╔|╗|║)" +_TIP_PATTERN = r"^\s*Tips?:" +_FOOTER_LINE_PATTERN = r"(?:\? for shortcuts|esc to cancel|shift \+ tab to cycle|Type your message or @|YOLO mode|Auto mode|plan mode|Accepting edits|➜ )" + +# qwen-code ships a native ``send_message`` tool (its team / background-task +# messaging feature — class ``_SendMessageTool``, described as "Send a message +# to a teammate … or to a running background task"). Its *bare* name collides +# with cao-mcp-server's ``send_message``, which qwen exposes under the prefixed +# name ``mcp__cao-mcp-server__send_message``. When a CAO worker is told to +# "send_message" its result back, the model matches the shorter native tool and +# calls it → "No active team and no task_id provided" → the assign/handoff +# callback never routes back to the supervisor. CAO orchestration never uses +# qwen-code's native team messaging (it routes through cao-mcp-server), so we +# drop the native tool via ``--exclude-tools`` and leave the MCP tool as the +# only send-message-shaped tool the model can pick. See issue #376. +QWEN_CONFLICTING_NATIVE_TOOL = "send_message" + + +class QwenCliProvider(BaseProvider): + """Provider for Qwen Code (``qwen``). + + Manages the lifecycle of a ``qwen`` REPL session inside a tmux window: + initialization (with profile system prompt, model, and MCP config), status + detection, response extraction, and cleanup. + + Attributes: + terminal_id: Unique identifier for this terminal instance. + session_name: Name of the tmux session containing this terminal. + window_name: Name of the tmux window for this terminal. + _agent_profile: Optional CAO agent profile name to load. + _model: Optional model override forwarded as ``--model``. + """ + + def __init__( + self, + terminal_id: str, + session_name: str, + window_name: str, + agent_profile: Optional[str] = None, + allowed_tools: Optional[list] = None, + model: Optional[str] = None, + skill_prompt: Optional[str] = None, + ): + """Initialize the Qwen Code provider. + + Args: + terminal_id: Unique identifier for this terminal. + session_name: Name of the tmux session. + window_name: Name of the tmux window. + agent_profile: Optional CAO agent profile name. + allowed_tools: Optional list of CAO tool names the agent may use. + When restricted (not wildcard), the security prompt is appended + to the injected system prompt for soft enforcement. + model: Optional model override forwarded as ``--model``. The + profile's ``model`` field takes precedence when set. + skill_prompt: Optional skill catalog text built by the service + layer. Appended to the system prompt at launch. + """ + super().__init__(terminal_id, session_name, window_name, allowed_tools, skill_prompt) + self._initialized = False + self._agent_profile = agent_profile + self._model = model + # Per-terminal temp files (the --mcp-config JSON), removed on cleanup(). + self._tmp_paths: list[Path] = [] + # Turn counter. get_status() returns IDLE while _turns == 0 (fresh + # spawn / post-init, no task delivered yet) and COMPLETED once at least + # one turn has been delivered and the agent is back to a ready input box. + # The ready TUI looks identical in both states, so the counter is the + # authoritative IDLE-vs-COMPLETED signal. Incremented by + # mark_input_received(), which the terminal service calls after every + # send_input(). This keeps the handoff/assign "wait for IDLE before + # sending the task" contract working right after init. + self._turns: int = 0 + + @property + def blocks_orchestrated_input_while_waiting_user_answer(self) -> bool: + """qwen's approval dialogs / pickers consume pasted text as the answer. + + Even with ``--approval-mode yolo`` some interactive prompts can surface; + when one is up, an orchestrated assign/handoff message pasted into the + input would be read as the prompt's answer. Opting in makes the terminal + service hold orchestrated delivery until the prompt clears, while still + allowing explicit user-prompt answers. + """ + return True + + # ------------------------------------------------------------------ # + # Launch + # ------------------------------------------------------------------ # + + def _write_mcp_config(self, mcp_servers: dict) -> Path: + """Write a per-terminal MCP config file and return its path. + + qwen's ``--mcp-config`` accepts a path to a JSON file of shape + ``{"mcpServers": {...}}``. We write one file per terminal (tracked in + ``_tmp_paths`` and removed on cleanup) so concurrent terminals never + race on a shared config. ``CAO_TERMINAL_ID`` is forwarded into each + server's ``env`` so cao-mcp-server can resolve the current terminal for + handoff / assign. + """ + servers: dict = {} + for server_name, server_config in mcp_servers.items(): + if isinstance(server_config, dict): + cfg = dict(server_config) + else: + cfg = server_config.model_dump(exclude_none=True) + entry = { + "command": cfg.get("command", ""), + "args": cfg.get("args", []), + } + env = dict(cfg.get("env", {})) + env["CAO_TERMINAL_ID"] = self.terminal_id + entry["env"] = env + servers[server_name] = entry + + fd, path_str = tempfile.mkstemp(prefix="cao_qwen_mcp_", suffix=".json") + with os.fdopen(fd, "w") as f: + json.dump({"mcpServers": servers}, f, indent=2) + path = Path(path_str) + self._tmp_paths.append(path) + return path + + def _build_qwen_command(self) -> str: + """Build the ``qwen`` launch command. + + Structure:: + + qwen --approval-mode yolo [--model ""] \ + [--append-system-prompt ""] \ + [--mcp-config ""] + + ``--approval-mode yolo`` auto-approves tool calls (required for + unattended orchestration). ``--model`` selects the model. The agent + profile's system prompt (+ skill catalog + security prompt when tool- + restricted) is layered on top of qwen's built-in agent prompt via + ``--append-system-prompt``. MCP servers are written to a per-terminal + file referenced by ``--mcp-config``. + + Returns a shell-escaped command string for ``send_keys``. + """ + binary = shutil.which("qwen") + if not binary: + raise ProviderError( + "Qwen Code not found: 'qwen' is not on $PATH. " + "Install via: npm install -g @qwen-code/qwen-code" + ) + + # Drop qwen-code's native ``send_message`` tool so it can't shadow + # cao-mcp-server's ``send_message`` in orchestration callbacks (#376). + command_parts = [ + "qwen", + "--approval-mode", + "yolo", + "--exclude-tools", + QWEN_CONFLICTING_NATIVE_TOOL, + ] + + profile = None + if self._agent_profile is not None: + try: + profile = load_agent_profile(self._agent_profile) + except Exception as exc: + raise ProviderError(f"Failed to load agent profile '{self._agent_profile}': {exc}") + + # Model: profile.model wins over the constructor-provided override. + model = self._model + if profile is not None and profile.model: + model = profile.model + if model: + command_parts.extend(["--model", model]) + + # System prompt injection via --append-system-prompt (layers the CAO + # role on top of qwen's own agent prompt; interactive-safe). + if profile is not None: + system_prompt = profile.system_prompt or "" + system_prompt = self._apply_skill_prompt(system_prompt) + # Soft tool restriction: when the profile is not allowed every tool + # (e.g. the read-only reviewer), append the security prompt. qwen + # honors a clear instruction not to use disallowed tools. + if self._allowed_tools and "*" not in self._allowed_tools: + system_prompt = ( + f"{system_prompt}\n\n{SECURITY_PROMPT}" if system_prompt else SECURITY_PROMPT + ) + if system_prompt: + command_parts.extend(["--append-system-prompt", system_prompt]) + + # MCP servers (cao-mcp-server etc.) → per-terminal --mcp-config file. + if profile.mcpServers: + mcp_path = self._write_mcp_config(profile.mcpServers) + command_parts.extend(["--mcp-config", str(mcp_path)]) + + return shlex.join(command_parts) + + async def initialize(self) -> bool: + """Initialize the Qwen Code provider by starting ``qwen``. + + 1. Wait for the shell prompt in the tmux window. + 2. Send the ``qwen`` command (model + system prompt + MCP config). + 3. Dismiss any first-run startup dialog (theme / trust). + 4. Wait for the agent to reach IDLE / COMPLETED. + + Raises: + TimeoutError: If the shell or qwen initialization times out. + """ + if not await wait_for_shell(self.terminal_id, timeout=10.0): + raise TimeoutError("Shell initialization timed out after 10 seconds") + + command = self._build_qwen_command() + + # Arm the StatusMonitor stickiness gate so the launch drives a fresh + # PROCESSING transition past any stale ready latch. Imported lazily to + # avoid a circular import (status_monitor imports provider_manager). + from cli_agent_orchestrator.services.status_monitor import status_monitor + + status_monitor.notify_input_sent(self.terminal_id) + get_backend().send_keys(self.session_name, self.window_name, command) + + # Dismiss the first-run theme / trust dialog if qwen shows one. With + # OpenAI-compatible credentials in the environment qwen skips the auth + # dialog and lands directly on the input prompt, but a fresh install may + # still show a theme picker or a folder-trust gate that blocks IDLE. + self._handle_startup_dialog() + + # qwen startup + first MCP connection (cao-mcp-server is fetched via uvx + # from git on first use) can take a while. + if not await wait_until_status( + self.terminal_id, + {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, + timeout=180.0, + ): + raise TimeoutError("Qwen Code initialization timed out after 180 seconds") + + self._initialized = True + return True + + def _handle_startup_dialog(self, timeout: Optional[float] = None) -> None: + """Dismiss qwen's blocking first-run dialogs (theme / folder-trust). + + Mirrors AntigravityCliProvider._handle_startup_dialog / KimiCliProvider. + Polls the pane and, if a theme picker or a folder-trust gate is showing, + accepts the pre-selected option with Enter. Both are one-shot on first + install; once qwen is at its ready input box the loop returns. When + credentials are already configured this is typically a no-op. + """ + if timeout is None: + timeout = get_server_settings()["startup_prompt_handler_timeout"] + from cli_agent_orchestrator.services.status_monitor import status_monitor + + start_time = time.time() + while time.time() - start_time < timeout: + output = get_backend().get_history(self.session_name, self.window_name) + if output: + clean = strip_terminal_escapes(output) + # Ready input box with no dialog pending → done. + if re.search(IDLE_FOOTER_PATTERN, clean) and not re.search( + STARTUP_DIALOG_PATTERN, clean + ): + return + if re.search(STARTUP_DIALOG_PATTERN, clean): + logger.info("Qwen Code startup dialog detected, accepting default") + status_monitor.notify_input_sent(self.terminal_id) + get_backend().send_special_key(self.session_name, self.window_name, "Enter") + time.sleep(1.0) + continue + time.sleep(1.0) + + # ------------------------------------------------------------------ # + # Status detection + # ------------------------------------------------------------------ # + + def get_status(self, output: Optional[str]) -> TerminalStatus: + """Detect qwen status from the terminal output buffer. + + Priority (matches the checks below in order): + 1. Empty → UNKNOWN + 2. WAITING_USER_ANSWER — an interactive approval / picker prompt + (takes precedence over the processing spinner) + 3. PROCESSING — "esc to cancel" (or a spinner timer) in the tail + 4. IDLE / COMPLETED — a ready input box (IDLE pre-first-turn, + COMPLETED after) + 5. ERROR — matched hard-error pattern + 6. UNKNOWN — nothing matched + + NOTE: the raw pipe-pane stream retains a stale "esc to cancel" after a + turn ends (the footer/spinner redraws in place), so this raw-stream path + can report a false PROCESSING once a turn is done. The authoritative + detector is get_status_from_screen() on a pyte-composited viewport; this + method is the fallback when CAO_PYTE_STATUS is off. + """ + # Native status (herdr): trust the backend's agent state when available; + # on herdr the buffer is never fed, so buffer parsing can't leave UNKNOWN. + native = self._resolve_native_status() + if native is not None: + return native + + if not output: + return TerminalStatus.UNKNOWN + + clean = strip_terminal_escapes(output) + tail = clean[-FOOTER_TAIL_WINDOW:] + + # Interactive prompt blocking on user input takes precedence over a + # plain processing state. + if re.search(WAITING_USER_ANSWER_PATTERN, tail): + return TerminalStatus.WAITING_USER_ANSWER + + if re.search(PROCESSING_FOOTER_PATTERN, tail) or any( + re.search(PROCESSING_SPINNER_PATTERN, line) for line in tail.splitlines() + ): + return TerminalStatus.PROCESSING + + # IDLE / COMPLETED: ready input box present. Fresh spawn (no delivered + # turn) is IDLE; a finished turn is COMPLETED. + if re.search(IDLE_FOOTER_PATTERN, tail): + return TerminalStatus.COMPLETED if self._turns > 0 else TerminalStatus.IDLE + + if re.search(ERROR_PATTERN, clean, re.MULTILINE): + return TerminalStatus.ERROR + + return TerminalStatus.UNKNOWN + + # Opt in to pyte rendered-screen detection (gated by CAO_PYTE_STATUS). + # The raw-stream get_status() above is unreliable for qwen: when the spinner + # ("esc to cancel") is redrawn away at turn end, the append-only pipe-pane + # log still contains it after strip_terminal_escapes(), so the stale marker + # pins the terminal to PROCESSING forever. A composited pyte viewport + # resolves the in-place redraw, leaving only the live frame. + supports_screen_detection = True + + def get_status_from_screen(self, screen_lines: List[str]) -> TerminalStatus: + """Detect qwen status from a pyte-composited viewport (escape-free rows). + + Same precedence as get_status, but anchored on the rendered bottom + region rather than the raw redraw stream. Because the viewport has every + in-place redraw already resolved, a live "esc to cancel" appears only + while the agent is actually working — eliminating the stale-spinner + false PROCESSING the raw-stream path suffers from. + + The StatusMonitor only invokes this on settled / rising-edge frames, so + the frame reflects a real end state, not a half-drawn one. + """ + rows = [ln.rstrip() for ln in screen_lines if ln.strip()] + if not rows: + return TerminalStatus.UNKNOWN + + joined = "\n".join(rows) + # The input box + footer live on the last rendered rows; the spinner + # sits a few rows above the input box. A bottom window keeps stale + # response text from matching while still covering the spinner. + bottom_rows = rows[-15:] + bottom = "\n".join(bottom_rows) + + if re.search(WAITING_USER_ANSWER_PATTERN, bottom): + return TerminalStatus.WAITING_USER_ANSWER + + if re.search(PROCESSING_FOOTER_PATTERN, bottom) or any( + re.search(PROCESSING_SPINNER_PATTERN, line) for line in bottom_rows + ): + return TerminalStatus.PROCESSING + + if re.search(IDLE_FOOTER_PATTERN, bottom): + return TerminalStatus.COMPLETED if self._turns > 0 else TerminalStatus.IDLE + + if re.search(ERROR_PATTERN, joined, re.MULTILINE): + return TerminalStatus.ERROR + + return TerminalStatus.UNKNOWN + + def get_idle_pattern_for_log(self) -> str: + """Return the qwen IDLE footer pattern for log-file pre-checks.""" + return IDLE_FOOTER_PATTERN_LOG + + # ------------------------------------------------------------------ # + # Response extraction + # ------------------------------------------------------------------ # + + def extract_last_message_from_script(self, script_output: str) -> str: + """Extract the agent's last response from rendered terminal output. + + Layout of a completed turn (rendered):: + + > + ● + + ───────────────────────────── (input box top rule) + * Type your message or @path/to/file + ───────────────────────────── (input box bottom rule) + ➜ · + YOLO mode (shift + tab to cycle) + + The response is the text between the last echoed ``> `` line and + the next full-width separator (the top of the input box). The assistant + bullet (``●``) is stripped and its text kept; TUI chrome (banner, + separators, footer, tips, spinner) is filtered out. + + Raises: + ValueError: When no response boundary is detected. + """ + clean = strip_terminal_escapes(script_output) + lines = clean.split("\n") + + # Index of the last echoed user query line. + last_query_idx: Optional[int] = None + for i, line in enumerate(lines): + if re.search(QUERY_PROMPT_PATTERN, line): + last_query_idx = i + if last_query_idx is None: + raise ValueError("No Qwen Code user query found - no '> ' line detected") + + # Response ends at the first separator after the query (input-box top). + end_idx = len(lines) + for i in range(last_query_idx + 1, len(lines)): + if re.search(SEPARATOR_PATTERN, lines[i]): + end_idx = i + break + + def _is_chrome(text_line: str) -> bool: + """True if the line is recognized TUI chrome (not response content).""" + stripped_line = text_line.strip() + return bool( + re.search(SEPARATOR_PATTERN, text_line) + or re.search(_FOOTER_LINE_PATTERN, stripped_line) + or re.search(_TIP_PATTERN, stripped_line) + or re.search(PROCESSING_SPINNER_PATTERN, stripped_line) + or re.search(_BANNER_PATTERN, stripped_line) + ) + + body = lines[last_query_idx + 1 : end_idx] + response_lines: list[str] = [] + for line in body: + if not line.strip(): + continue + if _is_chrome(line): + continue + # Strip the assistant/tool bullet ("● ") but keep the text. + text = re.sub(RESPONSE_BULLET_PATTERN, "", line).strip() + if text: + response_lines.append(text) + + response = "\n".join(response_lines).strip() + if not response: + raise ValueError("Empty Qwen Code response - no content found after query") + return response + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + def exit_cli(self) -> str: + """Get the command to exit qwen. ``/quit`` is the slash command.""" + return "/quit" + + def cleanup(self) -> None: + """Remove per-terminal temp files (MCP config) and reset state.""" + for path in self._tmp_paths: + try: + path.unlink() + except OSError: + pass + self._tmp_paths = [] + self._initialized = False + + def mark_input_received(self) -> None: + """Record that a turn was delivered (IDLE → COMPLETED on next status).""" + super().mark_input_received() + self._turns += 1 diff --git a/src/cli_agent_orchestrator/services/terminal_service.py b/src/cli_agent_orchestrator/services/terminal_service.py index 36a9954c8..1893e8ef1 100644 --- a/src/cli_agent_orchestrator/services/terminal_service.py +++ b/src/cli_agent_orchestrator/services/terminal_service.py @@ -128,6 +128,7 @@ class OutputMode(str, Enum): ProviderType.CODEX.value, ProviderType.KIMI_CLI.value, ProviderType.ANTIGRAVITY_CLI.value, + ProviderType.QWEN_CLI.value, } # Providers whose tool restrictions are prompt-level text only (no native @@ -136,6 +137,7 @@ class OutputMode(str, Enum): ProviderType.KIMI_CLI.value, ProviderType.CODEX.value, ProviderType.ANTIGRAVITY_CLI.value, + ProviderType.QWEN_CLI.value, } diff --git a/src/cli_agent_orchestrator/utils/tool_mapping.py b/src/cli_agent_orchestrator/utils/tool_mapping.py index c0f4ace3f..da09c9b66 100644 --- a/src/cli_agent_orchestrator/utils/tool_mapping.py +++ b/src/cli_agent_orchestrator/utils/tool_mapping.py @@ -69,6 +69,26 @@ ], "web_fetch": ["web_fetch", "google_web_search"], }, + # Qwen Code (qwen) is a Gemini-CLI fork and shares the same gemini-style tool + # vocabulary (write_file/read_file/run_shell_command/...). Restrictions are + # enforced softly via the injected security prompt (see + # SOFT_ENFORCEMENT_PROVIDERS); this mapping only drives the launch-time + # confirmation summary. + "qwen_cli": { + "execute_bash": ["run_shell_command"], + "fs_read": ["read_file", "list_directory", "search_file_content", "glob"], + "fs_write": ["write_file", "replace"], + "fs_list": ["list_directory", "glob", "search_file_content"], + "fs_*": [ + "read_file", + "write_file", + "replace", + "list_directory", + "search_file_content", + "glob", + ], + "web_fetch": ["web_fetch", "google_web_search"], + }, } # Complete set of all native tools per provider (used to compute disallowed set). diff --git a/test/api/test_api_endpoints.py b/test/api/test_api_endpoints.py index c06a6c6d4..dbc83173c 100644 --- a/test/api/test_api_endpoints.py +++ b/test/api/test_api_endpoints.py @@ -122,7 +122,7 @@ def test_list_providers_all_installed(self, client): assert response.status_code == 200 data = response.json() - assert len(data) == 9 + assert len(data) == 10 names = [p["name"] for p in data] assert "kiro_cli" in names assert "claude_code" in names @@ -133,6 +133,7 @@ def test_list_providers_all_installed(self, client): assert "opencode_cli" in names assert "cursor_cli" in names assert "antigravity_cli" in names + assert "qwen_cli" in names for p in data: assert p["installed"] is True @@ -179,6 +180,7 @@ def test_list_providers_has_binary_field(self, client): assert providers_dict["copilot_cli"]["binary"] == "copilot" assert providers_dict["opencode_cli"]["binary"] == "opencode" assert providers_dict["antigravity_cli"]["binary"] == "agy" + assert providers_dict["qwen_cli"]["binary"] == "qwen" # ── Skills endpoint ────────────────────────────────────────────────── diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index e74fe9493..fefc99d84 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -123,6 +123,13 @@ def require_antigravity(): pytest.skip("Antigravity CLI (agy) not installed") +@pytest.fixture() +def require_qwen_cli(): + """Skip test if Qwen Code (``qwen``) is not available.""" + if not _cli_available("qwen"): + pytest.skip("Qwen Code (qwen) not installed") + + @pytest.fixture() def require_cursor(): """Skip test if Cursor CLI (agent or cursor-agent) is not available.""" diff --git a/test/e2e/test_allowed_tools.py b/test/e2e/test_allowed_tools.py index 919779bee..0bc9b0617 100644 --- a/test/e2e/test_allowed_tools.py +++ b/test/e2e/test_allowed_tools.py @@ -639,3 +639,45 @@ def test_allowed_tools_stored_in_metadata(self, require_antigravity): agent_profile="developer", allowed_tools="@builtin,fs_read,@cao-mcp-server", ) + + +# --------------------------------------------------------------------------- +# Qwen Code provider — soft enforcement via SECURITY_PROMPT +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +class TestQwenCliAllowedTools: + """E2E allowedTools tests for the Qwen Code provider. + + Qwen Code launches with ``--approval-mode yolo`` and does not enforce a + native tool denylist for CAO; restrictions are enforced softly via the + SECURITY_PROMPT appended to the system prompt. This is advisory only. + """ + + @pytest.mark.xfail( + reason="Qwen Code lacks native CAO tool blocking under yolo; soft enforcement is advisory only", + strict=False, + ) + def test_restricted_supervisor_cannot_bash(self, require_qwen_cli): + """Supervisor with only @cao-mcp-server should not execute bash.""" + _run_restricted_tool_test( + provider="qwen_cli", + agent_profile="code_supervisor", + allowed_tools="@cao-mcp-server", + ) + + def test_unrestricted_developer_can_bash(self, require_qwen_cli): + """Developer with wildcard allowedTools can execute bash.""" + _run_unrestricted_tool_test( + provider="qwen_cli", + agent_profile="developer", + ) + + def test_allowed_tools_stored_in_metadata(self, require_qwen_cli): + """allowed_tools is persisted and returned by GET /terminals.""" + _run_allowed_tools_stored_test( + provider="qwen_cli", + agent_profile="developer", + allowed_tools="@builtin,fs_read,@cao-mcp-server", + ) diff --git a/test/e2e/test_assign.py b/test/e2e/test_assign.py index 929a82e84..16978d189 100644 --- a/test/e2e/test_assign.py +++ b/test/e2e/test_assign.py @@ -651,3 +651,59 @@ def test_assign_report_generator(self, require_antigravity): def test_assign_with_callback(self, require_antigravity): """Antigravity CLI full round-trip: worker completes → sends result → supervisor receives.""" _run_assign_with_callback_test(provider="antigravity_cli") + + +# --------------------------------------------------------------------------- +# Qwen Code provider +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +class TestQwenCliAssign: + """E2E assign tests for the Qwen Code provider using examples/assign/ profiles. + + Requires: + - ``qwen`` binary on PATH (skip otherwise via ``require_qwen_cli`` fixture). + - Running CAO server. + - Agent profiles installed for qwen_cli:: + + cao install examples/assign/data_analyst.md --provider qwen_cli + cao install examples/assign/report_generator.md --provider qwen_cli + cao install developer --provider qwen_cli # for callback test + + Run:: + + uv run pytest -m e2e test/e2e/test_assign.py -k Qwen -v + """ + + def test_assign_data_analyst(self, require_qwen_cli): + """Qwen Code data_analyst receives dataset, performs statistical analysis. + + Like Gemini CLI, qwen's data_analyst profile tends to call send_message + rather than print raw results, so we accept the broader orchestration + keywords in addition to the statistical ones. + """ + _run_assign_test( + provider="qwen_cli", + agent_profile="data_analyst", + task_message=DATA_ANALYST_TASK, + content_keywords=DATA_ANALYST_KEYWORDS + + [ + "analysis", + "send_message", + "CAO_TERMINAL_ID", + ], + ) + + def test_assign_report_generator(self, require_qwen_cli): + """Qwen Code report_generator creates a report template.""" + _run_assign_test( + provider="qwen_cli", + agent_profile="report_generator", + task_message=REPORT_GENERATOR_TASK, + content_keywords=REPORT_GENERATOR_KEYWORDS, + ) + + def test_assign_with_callback(self, require_qwen_cli): + """Qwen Code full round-trip: worker completes → sends result → supervisor receives.""" + _run_assign_with_callback_test(provider="qwen_cli") diff --git a/test/e2e/test_handoff.py b/test/e2e/test_handoff.py index f2d5832b8..42a2d3150 100644 --- a/test/e2e/test_handoff.py +++ b/test/e2e/test_handoff.py @@ -359,3 +359,37 @@ def test_handoff_second_task(self, require_antigravity): ), content_keywords=["square", "return", "def"], ) + + +# --------------------------------------------------------------------------- +# Qwen Code provider +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +class TestQwenCliHandoff: + """E2E handoff tests for the Qwen Code provider.""" + + def test_handoff_simple_function(self, require_qwen_cli): + """Qwen Code developer creates a simple Python function and returns output.""" + _run_handoff_test( + provider="qwen_cli", + agent_profile="developer", + task_message=( + "Create a Python function called 'greet' that takes a name parameter " + "and returns 'Hello, {name}!'. Output only the function code." + ), + content_keywords=["greet", "hello", "def"], + ) + + def test_handoff_second_task(self, require_qwen_cli): + """Qwen Code developer handles a second independent task.""" + _run_handoff_test( + provider="qwen_cli", + agent_profile="developer", + task_message=( + "Create a Python function called 'square' that takes a parameter n " + "and returns n squared. Output only the function code." + ), + content_keywords=["square", "return", "def"], + ) diff --git a/test/e2e/test_send_message.py b/test/e2e/test_send_message.py index 970727fac..2e3945661 100644 --- a/test/e2e/test_send_message.py +++ b/test/e2e/test_send_message.py @@ -300,3 +300,17 @@ class TestAntigravityCliSendMessage: def test_send_message_to_inbox(self, require_antigravity): """Send a message to another Antigravity CLI terminal's inbox and verify delivery.""" _run_send_message_test(provider="antigravity_cli", agent_profile="developer") + + +# --------------------------------------------------------------------------- +# Qwen Code provider +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +class TestQwenCliSendMessage: + """E2E send_message tests for the Qwen Code provider.""" + + def test_send_message_to_inbox(self, require_qwen_cli): + """Send a message to another Qwen Code terminal's inbox and verify delivery.""" + _run_send_message_test(provider="qwen_cli", agent_profile="developer") diff --git a/test/e2e/test_skills.py b/test/e2e/test_skills.py index dfd501f48..61864601e 100644 --- a/test/e2e/test_skills.py +++ b/test/e2e/test_skills.py @@ -189,6 +189,15 @@ def test_skill_catalog_injected(self, require_antigravity): _run_skill_injection_test(provider="antigravity_cli", agent_profile="developer") +@pytest.mark.e2e +class TestQwenCliSkills: + """E2E skill injection tests for the Qwen Code provider.""" + + def test_skill_catalog_injected(self, require_qwen_cli): + """Qwen Code terminal command contains the injected skill catalog.""" + _run_skill_injection_test(provider="qwen_cli", agent_profile="developer") + + # --------------------------------------------------------------------------- # Skill API endpoint test (deterministic — no agent needed) # --------------------------------------------------------------------------- diff --git a/test/e2e/test_supervisor_orchestration.py b/test/e2e/test_supervisor_orchestration.py index 43fce98f9..a2a8e0f84 100644 --- a/test/e2e/test_supervisor_orchestration.py +++ b/test/e2e/test_supervisor_orchestration.py @@ -759,3 +759,21 @@ def test_supervisor_handoff(self, require_antigravity): def test_supervisor_assign_and_handoff(self, require_antigravity): """Supervisor uses assign + handoff to orchestrate multi-agent workflow.""" _run_supervisor_assign_test(provider="antigravity_cli") + + +# --------------------------------------------------------------------------- +# Qwen Code provider +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +class TestQwenCliSupervisorOrchestration: + """E2E supervisor orchestration tests for the Qwen Code provider.""" + + def test_supervisor_handoff(self, require_qwen_cli): + """Supervisor uses handoff MCP tool to delegate to report_generator.""" + _run_supervisor_handoff_test(provider="qwen_cli") + + def test_supervisor_assign_and_handoff(self, require_qwen_cli): + """Supervisor uses assign + handoff to orchestrate multi-agent workflow.""" + _run_supervisor_assign_test(provider="qwen_cli") diff --git a/test/providers/fixtures/qwen_cli_completed.txt b/test/providers/fixtures/qwen_cli_completed.txt new file mode 100644 index 000000000..75f11dfee --- /dev/null +++ b/test/providers/fixtures/qwen_cli_completed.txt @@ -0,0 +1,40 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: Add a QWEN.md file to give Qwen Code persistent project context. + + > What is 2+2? + ● 2 + 2 equals 4. + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +*   Type your message or @path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ➜ qwen-fixture-ws · qwen3-coder-plus + YOLO mode (shift + tab to cycle) + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/providers/fixtures/qwen_cli_error.txt b/test/providers/fixtures/qwen_cli_error.txt new file mode 100644 index 000000000..0f34bad65 --- /dev/null +++ b/test/providers/fixtures/qwen_cli_error.txt @@ -0,0 +1,40 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: Add a QWEN.md file to give Qwen Code persistent project context. + + > What is 2+2? + ✕ [API Error: Connection error. (cause: fetch failed)] (Press Ctrl+Y to retry) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +*   Type your message or @path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ➜ qwen-fixture-ws · qwen3-coder-plus + YOLO mode (shift + tab to cycle) + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/providers/fixtures/qwen_cli_idle.txt b/test/providers/fixtures/qwen_cli_idle.txt new file mode 100644 index 000000000..eb66005de --- /dev/null +++ b/test/providers/fixtures/qwen_cli_idle.txt @@ -0,0 +1,40 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: You can resume a previous conversation by running qwen --continue or qwen --resume. + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +*   Type your message or @path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ➜ qwen-fixture-ws · qwen3-coder-plus + YOLO mode (shift + tab to cycle) + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/providers/fixtures/qwen_cli_processing.txt b/test/providers/fixtures/qwen_cli_processing.txt new file mode 100644 index 000000000..f934eb478 --- /dev/null +++ b/test/providers/fixtures/qwen_cli_processing.txt @@ -0,0 +1,40 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: You can run any shell commands from Qwen Code using ! (e.g. !ls). + + > What is 2+2? Answer in one short sentence. + + .. Figuring out how to make this more witty... (16.5s · esc to cancel) +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +*   Type your message or @path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ➜ qwen-fixture-ws · qwen3-coder-plus + YOLO mode (shift + tab to cycle) + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/providers/fixtures/qwen_cli_response.txt b/test/providers/fixtures/qwen_cli_response.txt new file mode 100644 index 000000000..4172f56c6 --- /dev/null +++ b/test/providers/fixtures/qwen_cli_response.txt @@ -0,0 +1,41 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: Add a QWEN.md file to give Qwen Code persistent project context. + + > What is 2+2? + ● The answer is 4. + Two plus two equals four. Let me know if you want more detail. + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +*   Type your message or @path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ➜ qwen-fixture-ws · qwen3-coder-plus + YOLO mode (shift + tab to cycle) + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/providers/fixtures/qwen_cli_waiting.txt b/test/providers/fixtures/qwen_cli_waiting.txt new file mode 100644 index 000000000..c8661db6a --- /dev/null +++ b/test/providers/fixtures/qwen_cli_waiting.txt @@ -0,0 +1,23 @@ + +  ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄▄ ▄▄▄  ▄▄ ┌──────────────────────────────────────────────────────────┐ + ██╔═══██╗██║  ██║██╔════╝████╗ ██║ │ >_ Qwen Code (v0.19.8) │ + ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ │ │ + ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ │ API Key | qwen3-coder-plus (/model to change) │ + ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ │ /tmp/.../scratchpad/qwen-fixture-ws │ +  ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ └──────────────────────────────────────────────────────────┘ + + Tips: Add a QWEN.md file to give Qwen Code persistent project context. + + > What is 2+2? + ● Shell run_shell_command(ls -la) + + Allow execution of 'ls -la'? + + ● 1. Yes, allow once + 2. Yes, allow always + 3. No, suggest changes (esc) + + Waiting for user confirmation... + + + diff --git a/test/providers/test_provider_manager_unit.py b/test/providers/test_provider_manager_unit.py index 87bbc58db..0fa7179b1 100644 --- a/test/providers/test_provider_manager_unit.py +++ b/test/providers/test_provider_manager_unit.py @@ -53,6 +53,24 @@ def test_create_provider_hermes_stores_mapping(): assert manager.get_provider("t1") is provider +def test_create_provider_qwen_cli_stores_mapping(): + from cli_agent_orchestrator.providers.qwen_cli import QwenCliProvider + + manager = ProviderManager() + provider = manager.create_provider( + ProviderType.QWEN_CLI.value, + terminal_id="t1", + tmux_session="s1", + tmux_window="w1", + agent_profile=None, + model="qwen3-coder-plus", + ) + + assert isinstance(provider, QwenCliProvider) + assert provider._model == "qwen3-coder-plus" + assert manager.get_provider("t1") is provider + + def test_create_provider_unknown_type_raises(): manager = ProviderManager() with pytest.raises(ValueError, match="Unknown provider type"): diff --git a/test/providers/test_qwen_cli_unit.py b/test/providers/test_qwen_cli_unit.py new file mode 100644 index 000000000..27415d7e2 --- /dev/null +++ b/test/providers/test_qwen_cli_unit.py @@ -0,0 +1,629 @@ +"""Unit tests for the Qwen Code (``qwen``) provider.""" + +import json +import re +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cli_agent_orchestrator.models.terminal import TerminalStatus +from cli_agent_orchestrator.providers.qwen_cli import ( + IDLE_FOOTER_PATTERN, + PROCESSING_FOOTER_PATTERN, + ProviderError, + QwenCliProvider, +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +WHICH_QWEN = "cli_agent_orchestrator.providers.qwen_cli.shutil.which" +LOAD_PROFILE = "cli_agent_orchestrator.providers.qwen_cli.load_agent_profile" + + +def load_fixture(name: str) -> str: + return (FIXTURES_DIR / name).read_text(encoding="utf-8") + + +def make_provider( + agent_profile=None, allowed_tools=None, model=None, skill_prompt=None +) -> QwenCliProvider: + return QwenCliProvider( + terminal_id="test-tid", + session_name="test-session", + window_name="window-0", + agent_profile=agent_profile, + allowed_tools=allowed_tools, + model=model, + skill_prompt=skill_prompt, + ) + + +# --------------------------------------------------------------------------- # +# Status detection (against captured live qwen TUI fixtures) +# --------------------------------------------------------------------------- # + + +def test_status_idle_fixture(): + assert make_provider().get_status(load_fixture("qwen_cli_idle.txt")) == TerminalStatus.IDLE + + +def test_status_processing_fixture(): + assert ( + make_provider().get_status(load_fixture("qwen_cli_processing.txt")) + == TerminalStatus.PROCESSING + ) + + +def test_status_completed_after_turn(): + p = make_provider() + p.mark_input_received() # _turns -> 1 + assert p.get_status(load_fixture("qwen_cli_completed.txt")) == TerminalStatus.COMPLETED + + +def test_status_idle_vs_completed_split_on_turns(): + # Same ready-looking input box is IDLE before the first delivered turn. + p = make_provider() + assert p.get_status(load_fixture("qwen_cli_completed.txt")) == TerminalStatus.IDLE + + +def test_status_api_error_turn_is_completed_not_error(): + # A transient "✕ [API Error ...]" turn returns to the ready input box + # (retryable) — reported as COMPLETED, not a status error. + p = make_provider() + p.mark_input_received() + assert p.get_status(load_fixture("qwen_cli_error.txt")) == TerminalStatus.COMPLETED + + +def test_status_empty_is_unknown(): + assert make_provider().get_status("") == TerminalStatus.UNKNOWN + assert make_provider().get_status(None) == TerminalStatus.UNKNOWN + + +def test_status_returns_native_when_backend_reports_it(): + # On the herdr backend, native agent state short-circuits buffer parsing. + p = make_provider() + with patch.object(p, "_resolve_native_status", return_value=TerminalStatus.COMPLETED): + assert p.get_status("irrelevant buffer") == TerminalStatus.COMPLETED + + +def test_status_processing_takes_priority_over_idle_footer(): + # If both a live spinner and a ready footer appear, the "esc to cancel" tail + # wins on the raw stream. + buf = "shift + tab to cycle\n" * 5 + ("x" * 3000) + "\n.. Working... (2.5s · esc to cancel)\n" + assert make_provider().get_status(buf) == TerminalStatus.PROCESSING + + +def test_status_waiting_user_answer_fixture(): + assert ( + make_provider().get_status(load_fixture("qwen_cli_waiting.txt")) + == TerminalStatus.WAITING_USER_ANSWER + ) + + +def test_status_waiting_user_answer_yn_prompt(): + buf = "Do you want to proceed? [y/n]\n" + assert make_provider().get_status(buf) == TerminalStatus.WAITING_USER_ANSWER + + +def test_status_hard_error(): + assert make_provider().get_status("Error: something exploded\n") == TerminalStatus.ERROR + + +def test_status_unknown_when_no_markers(): + # Non-empty buffer with no footer / spinner / error markers -> UNKNOWN. + assert make_provider().get_status("just some banner text\n") == TerminalStatus.UNKNOWN + + +# --------------------------------------------------------------------------- # +# pyte rendered-screen status detection (get_status_from_screen) +# --------------------------------------------------------------------------- # + + +def test_provider_opts_into_screen_detection(): + assert make_provider().supports_screen_detection is True + + +def test_screen_status_processing_fixture(): + rows = load_fixture("qwen_cli_processing.txt").split("\n") + assert make_provider().get_status_from_screen(rows) == TerminalStatus.PROCESSING + + +def test_screen_status_completed_after_turn(): + p = make_provider() + p.mark_input_received() + rows = load_fixture("qwen_cli_completed.txt").split("\n") + assert p.get_status_from_screen(rows) == TerminalStatus.COMPLETED + + +def test_screen_status_idle_pre_first_turn(): + rows = load_fixture("qwen_cli_idle.txt").split("\n") + assert make_provider().get_status_from_screen(rows) == TerminalStatus.IDLE + + +def test_screen_status_waiting_user_answer(): + screen = ["Do you want to proceed? [y/n]"] + assert make_provider().get_status_from_screen(screen) == TerminalStatus.WAITING_USER_ANSWER + + +def test_screen_status_empty_is_unknown(): + assert make_provider().get_status_from_screen([]) == TerminalStatus.UNKNOWN + assert make_provider().get_status_from_screen([" ", ""]) == TerminalStatus.UNKNOWN + + +def test_screen_status_error(): + assert make_provider().get_status_from_screen(["Error: boom", "more"]) == TerminalStatus.ERROR + + +def test_screen_status_unknown_when_no_markers(): + assert ( + make_provider().get_status_from_screen(["just some text", "no markers here"]) + == TerminalStatus.UNKNOWN + ) + + +# --------------------------------------------------------------------------- # +# Startup-dialog handling (_handle_startup_dialog) +# --------------------------------------------------------------------------- # + + +@patch("cli_agent_orchestrator.providers.qwen_cli.time.sleep", return_value=None) +@patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") +def test_handle_startup_dialog_dismisses_then_returns_on_ready(mock_backend, _sleep): + backend = mock_backend.return_value + backend.get_history.side_effect = [ + "Do you trust the files in this folder? > Yes, proceed", # dialog + " YOLO mode (shift + tab to cycle)", # ready input box + ] + make_provider()._handle_startup_dialog(timeout=5.0) + # Accepted the pre-selected option with Enter. + backend.send_special_key.assert_called_once() + assert backend.send_special_key.call_args.args[2] == "Enter" + + +@patch("cli_agent_orchestrator.providers.qwen_cli.time.sleep", return_value=None) +@patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") +def test_handle_startup_dialog_noop_when_already_ready(mock_backend, _sleep): + backend = mock_backend.return_value + backend.get_history.return_value = " YOLO mode (shift + tab to cycle)" + make_provider()._handle_startup_dialog(timeout=5.0) + backend.send_special_key.assert_not_called() + + +@patch("cli_agent_orchestrator.providers.qwen_cli.get_server_settings") +@patch("cli_agent_orchestrator.providers.qwen_cli.time.sleep", return_value=None) +@patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") +def test_handle_startup_dialog_default_timeout_and_empty_output( + mock_backend, _sleep, mock_settings +): + # timeout=None resolves the server setting; an empty first poll falls through + # to the tail sleep, then the ready footer ends the loop. + mock_settings.return_value = {"startup_prompt_handler_timeout": 5.0} + backend = mock_backend.return_value + backend.get_history.side_effect = [None, " YOLO mode (shift + tab to cycle)"] + make_provider()._handle_startup_dialog() + mock_settings.assert_called_once() + backend.send_special_key.assert_not_called() + + +def test_screen_resolves_stale_processing_footer_regression(): + """Regression: the append-only stream keeps a stale "esc to cancel" after a + turn ends (qwen redraws the spinner away in place), so the raw get_status() + latches PROCESSING. A composited pyte viewport shows only the final ready + input box ⇒ IDLE/COMPLETED, so the session reaches ready instead of timing + out. + """ + p = make_provider() + # Raw append-only stream: stale spinner survives below the response, with the + # live ready footer rendered last — raw path wrongly reports PROCESSING. + raw = ( + "> analyze this\n ● here is the analysis\n" + + ".. Working... (3.0s · esc to cancel)\n" + + ("\n" * 3) + + " YOLO mode (shift + tab to cycle)\n" + ) + assert p.get_status(raw) == TerminalStatus.PROCESSING + + # Composited viewport: the in-place rewrite is resolved, leaving only the + # ready input box ⇒ IDLE (no delivered turn yet). + screen = [ + "> analyze this", + " ● here is the analysis", + "─" * 80, + "* Type your message or @path/to/file", + "─" * 80, + " YOLO mode (shift + tab to cycle)", + ] + assert p.get_status_from_screen(screen) == TerminalStatus.IDLE + + +# --------------------------------------------------------------------------- # +# Response extraction +# --------------------------------------------------------------------------- # + + +def test_extract_completed_response(): + assert ( + make_provider().extract_last_message_from_script(load_fixture("qwen_cli_completed.txt")) + == "2 + 2 equals 4." + ) + + +def test_extract_multiline_response_strips_bullet(): + out = make_provider().extract_last_message_from_script(load_fixture("qwen_cli_response.txt")) + assert out == "The answer is 4.\nTwo plus two equals four. Let me know if you want more detail." + assert "●" not in out + + +def test_extract_raises_without_query(): + with pytest.raises(ValueError, match="No Qwen Code user query"): + make_provider().extract_last_message_from_script("no query here\njust text\n") + + +_RULE = "─" * 30 # input-box separator (SEPARATOR_PATTERN needs >= 20) + + +def test_extract_filters_banner_tip_spinner_footer(): + script = ( + "> what is 2+2\n" + " ● Qwen Code (v0.19.8)\n" # banner chrome + " Tips: press something\n" # tip chrome + " .. Working... (1.0s · esc to cancel)\n" # spinner chrome + " YOLO mode (shift + tab to cycle)\n" # footer chrome + " ● The answer is 4.\n" # real content + f"{_RULE}\n" + "* Type your message or @path/to/file\n" + ) + out = make_provider().extract_last_message_from_script(script) + assert out == "The answer is 4." + + +def test_extract_raises_on_empty_response(): + script = f"> hello\n .. Working... (1.0s · esc to cancel)\n{_RULE}\n" + with pytest.raises(ValueError, match="Empty"): + make_provider().extract_last_message_from_script(script) + + +def test_extract_uses_last_query(): + script = ( + "> first question\n ● first answer\n" + f"{_RULE}\n" + "> second question\n ● second answer\n" + f"{_RULE}\n" + ) + assert make_provider().extract_last_message_from_script(script) == "second answer" + + +def test_extract_strips_ansi_codes(): + script = "> hi\n ● \x1b[32mHello\x1b[0m world\n" + _RULE + "\n" + out = make_provider().extract_last_message_from_script(script) + assert "\x1b" not in out + assert "Hello world" in out + + +# --------------------------------------------------------------------------- # +# Command building +# --------------------------------------------------------------------------- # + + +def test_build_command_raises_when_binary_missing(): + with patch(WHICH_QWEN, return_value=None): + with pytest.raises(ProviderError, match="not found"): + make_provider()._build_qwen_command() + + +def test_build_command_includes_yolo_and_model(): + with patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"): + cmd = make_provider(model="qwen3-coder-plus")._build_qwen_command() + assert cmd.startswith("qwen --approval-mode yolo") + assert "--model" in cmd and "qwen3-coder-plus" in cmd + + +def test_build_command_excludes_native_send_message(): + """qwen-code ships a native ``send_message`` team tool whose bare name + collides with cao-mcp-server's ``send_message`` (exposed to qwen as + ``mcp__cao-mcp-server__send_message``). Told to "send_message", the model + picks the native tool → "No active team" → assign/handoff callbacks never + route back to the supervisor. We exclude it so only the MCP tool remains. + See issue #376. + """ + with patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"): + cmd = make_provider()._build_qwen_command() + assert "--exclude-tools send_message" in cmd + + +def test_build_command_without_profile_has_no_system_prompt(): + with patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"): + cmd = make_provider()._build_qwen_command() + assert "--append-system-prompt" not in cmd + assert "--mcp-config" not in cmd + + +def test_build_command_injects_append_system_prompt(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile( + name="reviewer_qwen", description="Reviewer", system_prompt="You review code." + ) + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = make_provider(agent_profile="reviewer_qwen")._build_qwen_command() + assert "--append-system-prompt" in cmd + assert "You review code." in cmd + # No first-turn guard: --append-system-prompt is a true system prompt. + assert "Acknowledge your role" not in cmd + + +def test_build_command_appends_security_prompt_when_tool_restricted(): + from cli_agent_orchestrator.constants import SECURITY_PROMPT + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile( + name="reviewer_qwen", description="Reviewer", system_prompt="You review code." + ) + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = make_provider( + agent_profile="reviewer_qwen", allowed_tools=["fs_read", "fs_list"] + )._build_qwen_command() + assert SECURITY_PROMPT.split("\n", 1)[0] in cmd + + +def test_build_command_unrestricted_wildcard_omits_security_prompt(): + from cli_agent_orchestrator.constants import SECURITY_PROMPT + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile(name="dev_qwen", description="Dev", system_prompt="You write code.") + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = make_provider(agent_profile="dev_qwen", allowed_tools=["*"])._build_qwen_command() + assert SECURITY_PROMPT.split("\n", 1)[0] not in cmd + + +def test_build_command_includes_skill_catalog(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile(name="dev_qwen", description="Dev", system_prompt="You write code.") + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = make_provider( + agent_profile="dev_qwen", skill_prompt="## Available Skills\n- foo: bar" + )._build_qwen_command() + assert "Available Skills" in cmd + + +def test_profile_model_overrides_constructor_model(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile( + name="reviewer_qwen", + description="Reviewer", + system_prompt="You review code.", + model="qwen3-max", + ) + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = make_provider( + agent_profile="reviewer_qwen", model="qwen3-coder-plus" + )._build_qwen_command() + assert "qwen3-max" in cmd # profile wins + assert "qwen3-coder-plus" not in cmd + + +def test_build_command_raises_on_bad_profile(): + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, side_effect=RuntimeError("boom")), + ): + with pytest.raises(ProviderError, match="Failed to load agent profile"): + make_provider(agent_profile="missing")._build_qwen_command() + + +# --------------------------------------------------------------------------- # +# MCP config (per-terminal --mcp-config temp file) +# --------------------------------------------------------------------------- # + + +def _mcp_config_path_from_cmd(cmd: str) -> Path: + import shlex + + parts = shlex.split(cmd) + idx = parts.index("--mcp-config") + return Path(parts[idx + 1]) + + +def test_mcp_config_written_with_terminal_id(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile( + name="reviewer_qwen", + description="Reviewer", + system_prompt="You review code.", + mcpServers={"cao-mcp-server": {"command": "uvx", "args": ["cao-mcp-server"]}}, + ) + p = make_provider(agent_profile="reviewer_qwen") + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = p._build_qwen_command() + cfg_path = _mcp_config_path_from_cmd(cmd) + try: + data = json.loads(cfg_path.read_text()) + assert "cao-mcp-server" in data["mcpServers"] + # CAO_TERMINAL_ID forwarded so cao-mcp-server can resolve the terminal. + assert data["mcpServers"]["cao-mcp-server"]["env"]["CAO_TERMINAL_ID"] == "test-tid" + finally: + cfg_path.unlink(missing_ok=True) + + +def test_mcp_config_accepts_pydantic_mcpserver(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile, McpServer + + profile = AgentProfile( + name="reviewer_qwen", + description="Reviewer", + system_prompt="You review code.", + mcpServers={"cao-mcp-server": McpServer(command="uvx", args=["cao-mcp-server"])}, + ) + p = make_provider(agent_profile="reviewer_qwen") + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = p._build_qwen_command() + cfg_path = _mcp_config_path_from_cmd(cmd) + try: + data = json.loads(cfg_path.read_text()) + assert data["mcpServers"]["cao-mcp-server"]["env"]["CAO_TERMINAL_ID"] == "test-tid" + assert data["mcpServers"]["cao-mcp-server"]["command"] == "uvx" + finally: + cfg_path.unlink(missing_ok=True) + + +def test_cleanup_deletes_mcp_temp_file(): + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + profile = AgentProfile( + name="reviewer_qwen", + description="Reviewer", + system_prompt="You review code.", + mcpServers={"cao-mcp-server": {"command": "uvx", "args": ["cao-mcp-server"]}}, + ) + p = make_provider(agent_profile="reviewer_qwen") + with ( + patch(WHICH_QWEN, return_value="/usr/local/bin/qwen"), + patch(LOAD_PROFILE, return_value=profile), + ): + cmd = p._build_qwen_command() + cfg_path = _mcp_config_path_from_cmd(cmd) + assert cfg_path.exists() + p.cleanup() + assert not cfg_path.exists() + assert p._tmp_paths == [] + assert p._initialized is False + + +def test_cleanup_noop_when_nothing_registered(): + p = make_provider() + p.cleanup() # must not raise + assert p._tmp_paths == [] + + +def test_cleanup_tolerates_already_deleted_file(): + p = make_provider() + p._tmp_paths = [Path("/tmp/cao_qwen_mcp_does_not_exist.json")] + p.cleanup() # must not raise on missing file + assert p._tmp_paths == [] + + +# --------------------------------------------------------------------------- # +# Lifecycle / misc +# --------------------------------------------------------------------------- # + + +def test_exit_cli_is_quit(): + assert make_provider().exit_cli() == "/quit" + + +def test_mark_input_received_increments_turns(): + p = make_provider() + assert p._turns == 0 + p.mark_input_received() + assert p._turns == 1 + + +def test_footer_patterns_smoke(): + assert re.search(PROCESSING_FOOTER_PATTERN, "esc to cancel") + assert re.search(IDLE_FOOTER_PATTERN, " YOLO mode (shift + tab to cycle)") + assert re.search(IDLE_FOOTER_PATTERN, "* Type your message or @path/to/file") + assert re.search(IDLE_FOOTER_PATTERN, "? for shortcuts") + + +def test_blocks_orchestrated_input_while_waiting_user_answer(): + assert make_provider().blocks_orchestrated_input_while_waiting_user_answer is True + + +def test_get_idle_pattern_for_log(): + pat = make_provider().get_idle_pattern_for_log() + assert re.search(pat, " YOLO mode (shift + tab to cycle)") + + +def test_paste_enter_count_is_valid(): + assert make_provider().paste_enter_count in (1, 2) + + +# --------------------------------------------------------------------------- # +# initialize() — async +# --------------------------------------------------------------------------- # + + +class TestInitialize: + @pytest.fixture(autouse=True) + def _stub_startup_dialog(self): + # initialize() polls the pane to dismiss qwen's first-run theme/trust + # dialog; that path is exercised directly elsewhere. Stub it so the init + # tests stay fast and independent of the (mocked) get_history return type. + with patch.object(QwenCliProvider, "_handle_startup_dialog", return_value=None): + yield + + @pytest.mark.asyncio + @patch(WHICH_QWEN, return_value="/usr/local/bin/qwen") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") + async def test_initialize_success(self, mock_backend, mock_shell, mock_wait, _which): + mock_shell.return_value = True + mock_wait.return_value = True + provider = make_provider() + assert await provider.initialize() is True + assert provider._initialized is True + mock_backend.return_value.send_keys.assert_called_once() + sent = mock_backend.return_value.send_keys.call_args.args[2] + assert sent.startswith("qwen --approval-mode yolo") + + @pytest.mark.asyncio + @patch(WHICH_QWEN, return_value="/usr/local/bin/qwen") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") + async def test_initialize_shell_timeout(self, mock_backend, mock_shell, _which): + mock_shell.return_value = False + provider = make_provider() + with pytest.raises(TimeoutError, match="Shell initialization timed out"): + await provider.initialize() + mock_backend.return_value.send_keys.assert_not_called() + + @pytest.mark.asyncio + @patch(WHICH_QWEN, return_value="/usr/local/bin/qwen") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") + async def test_initialize_cli_timeout(self, mock_backend, mock_shell, mock_wait, _which): + mock_shell.return_value = True + mock_wait.return_value = False + provider = make_provider() + with pytest.raises(TimeoutError, match="Qwen Code initialization timed out"): + await provider.initialize() + + @pytest.mark.asyncio + @patch(WHICH_QWEN, return_value="/usr/local/bin/qwen") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.qwen_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.qwen_cli.get_backend") + async def test_initialize_sends_model_flag(self, mock_backend, mock_shell, mock_wait, _which): + mock_shell.return_value = True + mock_wait.return_value = True + provider = make_provider(model="qwen3-coder-plus") + await provider.initialize() + sent = mock_backend.return_value.send_keys.call_args.args[2] + assert "--model qwen3-coder-plus" in sent diff --git a/test/test_constants.py b/test/test_constants.py index 2fdacb586..de7098765 100644 --- a/test/test_constants.py +++ b/test/test_constants.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + class TestServerConstants: """Tests for server configuration constants.""" @@ -311,6 +313,105 @@ def test_event_bus_queue_size_falls_back_when_env_is_non_numeric(self): assert mod.EVENT_BUS_MAX_QUEUE_SIZE == 16384 +class TestEnvBool: + """Tests for the ``env_bool()`` tolerant truthy/falsy env parser. + + Regression guard for the ``CAO_PYTE_STATUS`` footgun: the previous + ``os.environ.get(...).lower() == "true"`` parse silently evaluated the + common truthy spellings ``1`` / ``yes`` / ``on`` as *False*, which disabled + the pyte screen-detector that qwen_cli (and antigravity_cli) require and + hung cross-provider handoffs. ``env_bool`` accepts the usual spellings. + """ + + @pytest.mark.parametrize( + "value", + ["1", "true", "TRUE", "True", "yes", "YES", "on", "ON", " true ", "\t1\n"], + ) + def test_recognized_truthy_values_return_true(self, value): + from cli_agent_orchestrator.constants import env_bool + + with patch.dict("os.environ", {"CAO_TEST_FLAG": value}, clear=False): + assert env_bool("CAO_TEST_FLAG", default=False) is True + + @pytest.mark.parametrize( + "value", + ["0", "false", "FALSE", "no", "NO", "off", "OFF", " 0 "], + ) + def test_recognized_falsy_values_return_false(self, value): + from cli_agent_orchestrator.constants import env_bool + + with patch.dict("os.environ", {"CAO_TEST_FLAG": value}, clear=False): + assert env_bool("CAO_TEST_FLAG", default=True) is False + + def test_unset_returns_default(self): + import os + + from cli_agent_orchestrator.constants import env_bool + + env_copy = os.environ.copy() + env_copy.pop("CAO_TEST_FLAG", None) + with patch.dict("os.environ", env_copy, clear=True): + assert env_bool("CAO_TEST_FLAG", default=True) is True + assert env_bool("CAO_TEST_FLAG", default=False) is False + + def test_empty_value_returns_default(self): + from cli_agent_orchestrator.constants import env_bool + + with patch.dict("os.environ", {"CAO_TEST_FLAG": ""}, clear=False): + assert env_bool("CAO_TEST_FLAG", default=True) is True + assert env_bool("CAO_TEST_FLAG", default=False) is False + + def test_unrecognized_value_returns_default(self): + """A typo like ``tru`` must fall back to the default, never silently flip.""" + from cli_agent_orchestrator.constants import env_bool + + with patch.dict("os.environ", {"CAO_TEST_FLAG": "tru"}, clear=False): + assert env_bool("CAO_TEST_FLAG", default=True) is True + assert env_bool("CAO_TEST_FLAG", default=False) is False + + def test_cao_pyte_status_1_enables_pyte(self): + """Regression: ``CAO_PYTE_STATUS=1`` must ENABLE pyte (previously it + silently disabled it and hung qwen handoffs).""" + import importlib + + import cli_agent_orchestrator.constants as constants_module + + try: + with patch.dict("os.environ", {"CAO_PYTE_STATUS": "1"}, clear=False): + importlib.reload(constants_module) + assert constants_module.CAO_PYTE_STATUS is True + finally: + # Restore module-level state from the real (unpatched) environment. + importlib.reload(constants_module) + + def test_cao_pyte_status_defaults_to_true_when_unset(self): + import importlib + import os + + import cli_agent_orchestrator.constants as constants_module + + env_copy = os.environ.copy() + env_copy.pop("CAO_PYTE_STATUS", None) + try: + with patch.dict("os.environ", env_copy, clear=True): + importlib.reload(constants_module) + assert constants_module.CAO_PYTE_STATUS is True + finally: + importlib.reload(constants_module) + + def test_cao_pyte_status_false_disables_pyte(self): + import importlib + + import cli_agent_orchestrator.constants as constants_module + + try: + with patch.dict("os.environ", {"CAO_PYTE_STATUS": "false"}, clear=False): + importlib.reload(constants_module) + assert constants_module.CAO_PYTE_STATUS is False + finally: + importlib.reload(constants_module) + + class TestOpenCodeConstants: """Tests for OpenCode provider path constants."""