From 8ca464b9aa15d33e2c6bdb6a0eeffd24eca75ba4 Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Wed, 14 Oct 2026 11:24:40 +0000 Subject: [PATCH 1/3] feat(agents) add Qwen Code as a supported agent Qwen Code (npm @qwen-code/qwen-code, binary qwen) is Alibaba's open-source terminal coding agent. It runs here the way CodeBuddy does, one headless `qwen --output-format stream-json` run per message with the prompt piped over stdin, workspace tools over --mcp-config, and the channel's session resumed with --resume. The stream is Claude Code's frame format, so the reply is read from the result frame and assistant frames only drive the progress ticker. Behaviour verified against the real CLI (0.25.1) that shaped the adapter - Auto-memory is turned off. Left on, every turn ends with a memory extractor subagent and the result frame waits for it. A resumed turn had its answer after 7s and its result after 194s, with 100k+ extra tokens. The adapter loads a system-defaults settings layer (QWEN_CODE_SYSTEM_DEFAULTS_PATH) that disables it, plus self-update and usage statistics. The user's own settings.json still wins, and a machine-wide system-defaults file is merged, not replaced. - --auth-type openai is passed whenever the agent has a key. The CLI only infers its OpenAI provider when a key AND a model are both set, otherwise it fails with "No auth type is selected". Key, base URL and model are taken as one set, from the agent's LLM_* fields or from OPENAI_*, never mixed. A blank base URL means Model Studio only for the agent's own key, so an OPENAI_API_KEY exported for another tool is never sent to DashScope. - Sessions are filed under the working directory, so a channel's session is only resumed from the directory it was created in, and a "No saved session found" resume is retried once from scratch. A stopped session stays resumable, so Stop keeps the conversation. - A model error is streamed as "[API Error ...]" assistant text before the is_error result frame. It is reported as an error, never posted as the answer. - On Windows the npm entry script relaunches the CLI as a child, and killing only the parent leaves the turn running, so Stop uses taskkill /T. On POSIX, SIGINT to the group ends the run in ~100ms. - The npm package runs as cli-entry.js under the launcher's Node (the CLI needs Node 22), and a standalone install on its bundled Node, so the system prompt never goes through cmd.exe and its length limit. - cron_create, cron_list, cron_delete and loop_wakeup are excluded, as scheduling inside a one-shot process never fires. Minimum supported version is 0.16.0, the first release that accepts --mcp-config. Wiring - registry/qwen-code.json with LLM_API_KEY, LLM_BASE_URL and LLM_MODEL mapped to OPENAI_*, synced to the bundled and backend copies, icon in every icon dir - paths.js detects the standalone installer's Windows dir and its QWEN_INSTALL_ROOT and QWEN_INSTALL_BIN_DIR overrides, and the detection matrix covers npm and both standalone installers - launcher CORE_AGENTS, model picker listing from Model Studio when the base URL is blank, icon slug, en and zh descriptions - workspace connect view brand colour, docs/agents/qwen-code.md Tests - qwen-code-stream.test.js and qwen-code.test.js (64), with a mock CLI that replays the real CLI's success, MCP, missing key, 401, stale resume and stop transcripts - stop-control.test.js now covers qwen-code - end to end with a real Model Studio key against a local workspace backend, a reply, three turns on one session, a workspace MCP tool writing todos, Stop mid tool clearing the CLI and its tool process in about a second, and the conversation continuing after Stop No package or launcher version is changed. --- docs/agents/qwen-code.md | 174 +++ packages/agent-connector/CLAUDE.md | 2 + packages/agent-connector/icons/qwen-code.svg | 1 + packages/agent-connector/registry.json | 94 ++ .../agent-connector/src/adapters/index.js | 5 +- .../src/adapters/qwen-code-stream.js | 679 +++++++++++ .../agent-connector/src/adapters/qwen-code.js | 1043 +++++++++++++++++ packages/agent-connector/src/paths.js | 6 + .../test/agent-detection-matrix.test.js | 9 + .../test/qwen-code-stream.test.js | 280 +++++ .../agent-connector/test/qwen-code.test.js | 519 ++++++++ .../agent-connector/test/stop-control.test.js | 6 +- .../launcher/src/main/agents/auth-specs.ts | 8 + .../launcher/src/main/agents/model-catalog.ts | 9 + .../src/renderer/components/AgentIcon.tsx | 1 + .../renderer/i18n/locales/en/agentMeta.json | 3 +- .../renderer/i18n/locales/zh/agentMeta.json | 3 +- .../src/renderer/public/icons/qwen-code.svg | 1 + .../launcher/src/shared/agent-credentials.ts | 1 + registry/icons/qwen-code.svg | 1 + registry/index.json | 3 +- registry/qwen-code.json | 94 ++ .../backend/registry/icons/qwen-code.svg | 1 + workspace/backend/registry/index.json | 3 +- workspace/backend/registry/qwen-code.json | 94 ++ .../components/connect/connect-agent-view.tsx | 1 + .../public/icons/agents/qwen-code.svg | 1 + 27 files changed, 3036 insertions(+), 6 deletions(-) create mode 100644 docs/agents/qwen-code.md create mode 100644 packages/agent-connector/icons/qwen-code.svg create mode 100644 packages/agent-connector/src/adapters/qwen-code-stream.js create mode 100644 packages/agent-connector/src/adapters/qwen-code.js create mode 100644 packages/agent-connector/test/qwen-code-stream.test.js create mode 100644 packages/agent-connector/test/qwen-code.test.js create mode 100644 packages/launcher/src/renderer/public/icons/qwen-code.svg create mode 100644 registry/icons/qwen-code.svg create mode 100644 registry/qwen-code.json create mode 100644 workspace/backend/registry/icons/qwen-code.svg create mode 100644 workspace/backend/registry/qwen-code.json create mode 100644 workspace/frontend/public/icons/agents/qwen-code.svg diff --git a/docs/agents/qwen-code.md b/docs/agents/qwen-code.md new file mode 100644 index 000000000..f86c5813a --- /dev/null +++ b/docs/agents/qwen-code.md @@ -0,0 +1,174 @@ +# Qwen Code + +[Qwen Code](https://github.com/QwenLM/qwen-code) — Alibaba's open-source +terminal coding agent for Qwen models, installed as `@qwen-code/qwen-code` +(binary `qwen`) — run as an OpenAgents agent. + +Verified end to end against Qwen Code 0.25.1. The minimum supported version is +0.16.0, the first release that accepts `--mcp-config`. + +## What the adapter does + +``` +qwen --output-format stream-json --approval-mode yolo \ + --exclude-tools cron_create cron_list cron_delete loop_wakeup \ + --auth-type openai --model --mcp-config \ + [--resume ] --append-system-prompt +``` + +One process per user message, with the prompt piped over stdin. With no +positional query the CLI reads the turn from stdin and runs it once, so a long +turn never meets the OS argv limit and a quote in a message cannot become a +command-line injection. + +The output is the same stream-json Claude Code writes: a `system/init` frame +with the session id, one `assistant` frame per content block, `user` frames +with tool results, and one closing `result` frame. The reply is read from +`result.result`. Assistant text and tool calls only drive the channel's +progress ticker. + +Workspace tools (`workspace_read_file`, the shared browser, todos, …) come +through the same stdio MCP server the Claude adapter uses, so the briefing is +the same one too. Qwen Code defers MCP tools. The model looks one up with +`tool_search` and calls it through a generic `tool_call` wrapper, and the +ticker names the tool that actually ran. + +Sessions are kept per channel. The CLI's `session_id` comes back on its first +frame, is stored with the working directory, and is passed back with +`--resume` on the next turn. Qwen Code files transcripts under the directory a +session ran in, so a session is only resumed from that same directory. If the +CLI answers a resume with "No saved session found", the binding is dropped and +the turn runs once more from scratch. + +## Authentication + +Qwen Code has no CLI sign-in: `qwen auth` was removed upstream, along with its +OAuth login. The agent runs on an OpenAI-compatible key: + +| Field | Passed to the CLI as | Notes | +|---|---|---| +| `LLM_API_KEY` | `OPENAI_API_KEY` (environment) | Required. Never put in argv. | +| `LLM_BASE_URL` | `OPENAI_BASE_URL` | Blank means Model Studio China, `https://dashscope.aliyuncs.com/compatible-mode/v1`. | +| `LLM_MODEL` | `--model` and `OPENAI_MODEL` | Blank means the CLI's default (`qwen3.7-max` on Model Studio). The workspace's model picker overrides it. | + +Base URLs for the common keys: + +| Key | Base URL | +|---|---| +| Model Studio (DashScope), China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | +| Model Studio, international | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | +| Bailian Coding Plan, China | `https://coding.dashscope.aliyuncs.com/v1` | +| Bailian Coding Plan, international | `https://coding-intl.dashscope.aliyuncs.com/v1` | + +A Model Studio key and a Coding Plan key are not interchangeable. Each fails +with a 401 against the other's URL. + +The adapter always passes `--auth-type openai` when it has a key. On its own, +the CLI only detects that provider when `OPENAI_API_KEY` **and** a model are +both set. A key and base URL without a model fail with "No auth type is +selected". + +The key, base URL and model are taken as one set: all three from the agent's +form, or all three from `OPENAI_*`. They are never mixed. Otherwise a blank +form base URL would pick up an `OPENAI_BASE_URL` exported for some other tool +and send the Qwen key there. + +When the agent has no key at all, nothing is overridden and the CLI's own +`~/.qwen/settings.json` decides. A provider set up with `/auth` in an +interactive session still works that way. + +## Settings the adapter changes + +Some settings have no command-line flag. The adapter writes them to +`~/.openagents/qwen-code/system-defaults.json` and points +`QWEN_CODE_SYSTEM_DEFAULTS_PATH` at that file. This is Qwen Code's +lowest-precedence settings scope, so your own `~/.qwen/settings.json` and a +project's `.qwen/settings.json` still override it. + +| Setting | Value | Why | +|---|---|---| +| `memory.enableManagedAutoMemory` | `false` | See below. | +| `memory.enableManagedAutoDream` | `false` | Background memory consolidation, same reason. | +| `general.enableAutoUpdate` | `false` | A self-update would replace the CLI, and the stream format the adapter parses, in the middle of a run. | +| `privacy.usageStatisticsEnabled` | `false` | The agent is not a person. | + +**Why auto-memory is off.** When it is on, every turn ends with a +"memory extractor" subagent, and the run's `result` frame waits for it to +finish. Measured on a resumed session, the answer was on stdout after 7 seconds +and the `result` frame arrived after 194 seconds, with over 100k extra tokens +spent. With auto-memory off, the same turn finished in 7 seconds. + +If the machine already has a system-defaults file (for example +`/etc/qwen-code/system-defaults.json`), it is merged in and its values win. If +that file exists but cannot be parsed, the adapter leaves it in charge and does +not override anything. + +## Tools that are turned off + +`cron_create`, `cron_list`, `cron_delete` and `loop_wakeup` schedule work +inside the CLI process. A headless run exits when its turn ends, so a reminder +set this way would never fire. Timers and routines that need to outlive the +turn go through the workspace, over MCP. + +In plan mode the run is `--approval-mode plan`, and the CLI removes its edit, +write and shell tools. Otherwise the run is `yolo`, because nobody is at a +keyboard to approve a tool call. Without `yolo`, a headless run silently drops +every write and shell tool. The CLI's per-run warning about `yolo` is silenced +with `QWEN_CODE_SUPPRESS_YOLO_WARNING=1` so that it cannot bury a real error on +stderr. + +## Stopping + +On macOS and Linux, Stop sends SIGINT to the run's process group, then SIGTERM +and SIGKILL if it is still running. Qwen Code handles SIGINT itself. It +cancels the turn, kills its own shell tools (they run in a PTY session outside +the group) and exits with code 130 within about 100 ms. The session can be +resumed afterwards, so the channel keeps its conversation. + +On Windows the npm entry script starts the CLI again as a child process +(`node --expose-gc cli.js`). `ChildProcess.kill()` there terminates only the +parent, which would leave the child running the turn. Stop therefore uses +`taskkill /T /F` on the whole tree. + +## Installing + +| Route | Where `qwen` lands | Detected | +|---|---|---| +| `npm install -g @qwen-code/qwen-code` (what the launcher runs) | the npm global prefix | yes | +| `install-qwen-standalone.sh` | `~/.local/bin/qwen`, a wrapper around `~/.local/lib/qwen-code` | yes | +| `install-qwen-standalone.ps1` | `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd`, a wrapper around `%LOCALAPPDATA%\qwen-code\qwen-code` | yes | +| either installer with `QWEN_INSTALL_ROOT` / `QWEN_INSTALL_BIN_DIR` | where those point | yes | + +The CLI needs Node 22 or newer. The adapter runs the npm package's +`cli-entry.js` under the launcher's managed Node, not through a `.cmd` shim, so +the system prompt never goes through `cmd.exe` and its 8191-character +command-line limit. A standalone install runs on the Node it ships with. + +## Storage + +| Path | Contents | +|---|---| +| `~/.openagents/sessions/__qwen-code.json` | The CLI session id per channel, and the working directory it was created in. | +| `~/.openagents/mcp-configs/qwen-code-*.json` | The workspace MCP config for one run. Written `0600` and deleted when the run ends, because it carries the workspace token. | +| `~/.openagents/qwen-code/system-defaults.json` | The settings layer described above. | +| `~/.qwen/projects//chats/.jsonl` | Qwen Code's own transcripts (`$QWEN_HOME` if set). | + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| "Qwen Code CLI not found" | Run `npm install -g @qwen-code/qwen-code`, or use the standalone installer. | +| "below the minimum supported version 0.16.0" | Older releases reject `--mcp-config`. Upgrade. | +| "has no model credentials" | No API key is set for the agent and `~/.qwen/settings.json` has no provider either. | +| "rejected the API key" | The key is wrong or expired, or it belongs to the other service (Model Studio vs Coding Plan). The provider's own message is quoted in brackets. | +| "does not serve the configured model" | The model id is not available at that base URL. Coding Plan offers a shorter list than Model Studio. | +| "could not reach the model endpoint" | The base URL is wrong, or there is a network or proxy problem. | +| Answers arrive but workspace tools do nothing | The MCP config could not be written, or the `openagents` binary was not found. The run goes ahead without workspace tools and the daemon log says so. | + +## Not supported yet + +- **Attachments** are flattened into the prompt as text. +- **Model reasoning** (`thinking` blocks) is not posted to the channel. The + Claude adapter does not post it either. +- **ACP** (`qwen --acp`) and **`qwen serve`** are not used. One headless run + per message is the only mode. diff --git a/packages/agent-connector/CLAUDE.md b/packages/agent-connector/CLAUDE.md index b746fdc09..747e2e186 100644 --- a/packages/agent-connector/CLAUDE.md +++ b/packages/agent-connector/CLAUDE.md @@ -44,6 +44,8 @@ src/ openworker-runtime.js Pure helpers for OpenWorkerAdapter (provider/model routing, server argv, secret-profile refs, session addressing, event interpretation, in-line prompt answers, failure classification) — unit-tested codebuddy.js CodeBuddyAdapter — Tencent CodeBuddy Code CLI (`codebuddy -p --output-format stream-json`) bridge, prompt piped over stdin, workspace tools over --mcp-config. Same engine the WorkBuddy desktop app runs codebuddy-stream.js Pure helpers for CodeBuddyAdapter (argv builder, stream-json frame interpreter, result-frame classification — the CLI exits 0 on failure — region/auth env, version gate, redaction) — unit-tested + qwen-code.js QwenCodeAdapter — Alibaba Qwen Code CLI (`qwen --output-format stream-json`) bridge, prompt piped over stdin, workspace tools over --mcp-config, auto-memory turned off through a system-defaults settings layer + qwen-code-stream.js Pure helpers for QwenCodeAdapter (argv builder, stream-json frame interpreter, run classification, key/base URL/model resolution as one set, settings layer, version gate, redaction) — unit-tested llm-direct.js LlmDirectAdapter — base for adapters that call LLM APIs directly (SSE streaming) index.js Adapter registry mapping type names to classes utils.js Shared adapter utilities diff --git a/packages/agent-connector/icons/qwen-code.svg b/packages/agent-connector/icons/qwen-code.svg new file mode 100644 index 000000000..fd63482d2 --- /dev/null +++ b/packages/agent-connector/icons/qwen-code.svg @@ -0,0 +1 @@ +Qwen Code diff --git a/packages/agent-connector/registry.json b/packages/agent-connector/registry.json index c7a11c56f..31ed30c76 100644 --- a/packages/agent-connector/registry.json +++ b/packages/agent-connector/registry.json @@ -2304,5 +2304,99 @@ ], "not_ready_message": "Needs a Huawei Cloud access key — set CODEARTS_CLI_AK and CODEARTS_CLI_SK (press e). Create one at https://codearts.huaweicloud.com/portal/settings/cli-auth." } + }, + { + "name": "qwen-code", + "label": "Qwen Code", + "description": "Alibaba's open-source terminal coding agent for Qwen models, driven headless", + "homepage": "https://github.com/QwenLM/qwen-code", + "tags": [ + "coding", + "cli", + "open-source", + "headless" + ], + "featured": false, + "order": 999, + "builtin": true, + "logo": { + "key": "qwen-code" + }, + "support": { + "install": true, + "workspace": true, + "collaboration": true + }, + "install": { + "binary": "qwen", + "npm_package": "@qwen-code/qwen-code", + "requires": [ + "nodejs" + ], + "verify": "qwen --version", + "verify_win": "qwen --version", + "macos": "npm install -g @qwen-code/qwen-code", + "linux": "npm install -g @qwen-code/qwen-code", + "windows": "npm install -g @qwen-code/qwen-code" + }, + "uninstall": { + "macos": "npm uninstall -g @qwen-code/qwen-code", + "linux": "npm uninstall -g @qwen-code/qwen-code", + "windows": "npm uninstall -g @qwen-code/qwen-code" + }, + "adapter": { + "module": "openagents.adapters.qwen_code", + "class": "QwenCodeAdapter" + }, + "launch": null, + "check_ready": { + "min_version": "0.16.0", + "env_vars": [ + "OPENAI_API_KEY" + ], + "saved_env_key": "LLM_API_KEY", + "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + }, + "env_config": [ + { + "name": "LLM_API_KEY", + "description": "API key for an OpenAI-compatible endpoint — a Model Studio (DashScope) key, or a Bailian Coding Plan key together with its own base URL.", + "required": true, + "password": true + }, + { + "name": "LLM_BASE_URL", + "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", + "required": false, + "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "name": "LLM_MODEL", + "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", + "required": false, + "placeholder": "qwen3.8-flash" + } + ], + "catalog": true, + "workspace_model": true, + "resolve_env": { + "rules": [ + { + "from": "LLM_API_KEY", + "to": "OPENAI_API_KEY" + }, + { + "from": "LLM_BASE_URL", + "to": "OPENAI_BASE_URL" + }, + { + "from": "LLM_MODEL", + "to": "OPENAI_MODEL" + } + ] + }, + "vendor": "Alibaba Qwen", + "tagline": "Qwen's terminal coding agent, on your Model Studio or Coding Plan key." } ] diff --git a/packages/agent-connector/src/adapters/index.js b/packages/agent-connector/src/adapters/index.js index c5267e09a..4a1ba2c93 100644 --- a/packages/agent-connector/src/adapters/index.js +++ b/packages/agent-connector/src/adapters/index.js @@ -27,6 +27,7 @@ const CommandCodeAdapter = require('./commandcode'); const OpenWorkerAdapter = require('./openworker'); const CodeBuddyAdapter = require('./codebuddy'); const CodeArtsAdapter = require('./codearts'); +const QwenCodeAdapter = require('./qwen-code'); const ADAPTER_MAP = { openclaw: OpenClawAdapter, @@ -51,11 +52,12 @@ const ADAPTER_MAP = { openworker: OpenWorkerAdapter, codebuddy: CodeBuddyAdapter, codearts: CodeArtsAdapter, + 'qwen-code': QwenCodeAdapter, }; /** * Create an adapter instance for the given agent type. - * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode, openworker, codebuddy, codearts) + * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode, openworker, codebuddy, codearts, qwen-code) * @param {object} opts - Adapter constructor options * @returns {BaseAdapter} */ @@ -90,6 +92,7 @@ module.exports = { CommandCodeAdapter, OpenWorkerAdapter, CodeArtsAdapter, + QwenCodeAdapter, createAdapter, ADAPTER_MAP, }; diff --git a/packages/agent-connector/src/adapters/qwen-code-stream.js b/packages/agent-connector/src/adapters/qwen-code-stream.js new file mode 100644 index 000000000..d6b7d4be8 --- /dev/null +++ b/packages/agent-connector/src/adapters/qwen-code-stream.js @@ -0,0 +1,679 @@ +/** + * Pure helpers for the Qwen Code adapter — argv construction, stream-json frame + * interpretation, run classification, credential and child-env resolution, the + * settings layer that turns auto-memory off, and the version gate. + * + * Split out from qwen-code.js so the parts with real decision logic are unit + * testable without spawning a CLI, an account, or a workspace. + * + * Four facts about Qwen Code's headless contract shape everything here, all + * verified against the real CLI (@qwen-code/qwen-code 0.25.1) rather than read + * off a docs page: + * + * 1. THE FRAMES ARE CLAUDE CODE'S. `-o stream-json` emits system/init, then + * one `assistant` frame per content block (thinking, text or tool_use), + * `user` frames carrying tool results, and one closing `result` frame. + * The answer is `result.result`; assistant frames only drive the ticker. + * 2. A FAILED RUN STILL WRITES A RESULT FRAME. Model and auth errors arrive + * as `result` with `is_error: true` and `error.message`, exit code 1, and + * nothing on stderr. The same error is ALSO streamed first as assistant + * text ("[API Error: 401 …]"), which must not be mistaken for an answer. + * Exit 130 is a SIGINT/SIGTERM stop (no result frame), 53 a turn cap and + * 55 a budget abort. + * 3. CREDENTIALS NEED AN EXPLICIT AUTH TYPE. The CLI only infers its OpenAI + * provider when OPENAI_API_KEY AND a model are both in the environment; a + * key and base URL without a model fails with "No auth type is selected". + * Passing `--auth-type openai` removes the guess. + * 4. AUTO-MEMORY HOLDS THE RESULT FRAME HOSTAGE. With the default + * `memory.enableManagedAutoMemory`, every turn ends with a memory- + * extractor subagent the run waits for: the answer was on stdout after 7s + * and the result frame after 194s, with 100k+ extra tokens spent. A + * system-defaults settings layer (QWEN_CODE_SYSTEM_DEFAULTS_PATH) turns it + * off without touching the user's own ~/.qwen/settings.json. + */ + +'use strict'; + +// Supported floor: the first release that accepts every flag buildQwenArgs() +// passes. 0.15.0 rejects `--mcp-config` as an unknown argument; 0.16.0 takes +// the whole argv (verified by running each release with it). +const QWEN_MIN_VERSION = '0.16.0'; +// Highest version whose behavior was verified end to end. Newer is allowed — +// it just isn't claimed as tested. +const QWEN_TESTED_MAX_VERSION = '0.25.1'; + +// Where a blank base URL points. Every Qwen Code user this agent is for holds +// a Model Studio (DashScope) key, and the launcher's model picker lists from +// the same endpoint — a blank URL reaching api.openai.com instead would fail +// as an "incorrect API key" that names neither. +const QWEN_DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + +// --------------------------------------------------------------------------- +// Version gate +// --------------------------------------------------------------------------- + +/** Extract a dotted version from `--version` output. Returns null if absent. */ +function parseQwenVersion(raw) { + if (!raw || typeof raw !== 'string') return null; + const m = raw.match(/(\d+)\.(\d+)\.(\d+)(?:[-.][0-9A-Za-z.]+)?/); + return m ? `${m[1]}.${m[2]}.${m[3]}` : null; +} + +/** Numeric compare of two dotted versions. Returns -1 / 0 / 1. */ +function compareVersions(a, b) { + const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = pa[i] || 0; + const y = pb[i] || 0; + if (x !== y) return x < y ? -1 : 1; + } + return 0; +} + +/** + * Classify a raw `--version` string. + * + * `supported: null` means "could not tell" — an unreadable version proceeds + * leniently rather than blocking a CLI that is probably fine. Only a version we + * positively read AND that is below the floor returns `false`. + */ +function classifyQwenVersion(rawVersion) { + const version = parseQwenVersion(rawVersion); + if (!version) return { version: null, supported: null, tested: null }; + return { + version, + supported: compareVersions(version, QWEN_MIN_VERSION) >= 0, + tested: compareVersions(version, QWEN_TESTED_MAX_VERSION) <= 0, + }; +} + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +const SECRET_PATTERNS = [ + // Provider API keys: sk-..., which covers Model Studio and Coding Plan keys. + /\bsk-[A-Za-z0-9._-]{8,}\b/g, + // Bearer tokens in copied headers. + /\bBearer\s+[A-Za-z0-9._-]{8,}\b/gi, + // JWTs, which is what the workspace token is. + /\beyJ[A-Za-z0-9._-]{16,}\b/g, + // Long opaque blobs assigned to a secret-looking key. + /\b(api[_-]?key|token|secret|password|authorization)\b\s*[:=]\s*["']?[A-Za-z0-9._-]{8,}["']?/gi, +]; + +/** Redact obvious secret material from free text before it is logged or posted. */ +function redactSecrets(text) { + if (text == null) return text; + let out = String(text); + for (const re of SECRET_PATTERNS) { + out = out.replace(re, (m) => { + const eq = m.match(/\s*[:=]\s*/); + if (eq) return m.slice(0, m.indexOf(eq[0])) + eq[0] + '[redacted]'; + return /^bearer/i.test(m) ? 'Bearer [redacted]' : '[redacted]'; + }); + } + return out; +} + +/** Truncate with an ellipsis, for previews and diagnostics. */ +function truncate(s, n) { + s = String(s == null ? '' : s); + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} + +/** + * Redact an argv for logging. + * + * The prompt is piped over stdin and the key travels in the environment, so + * neither appears here. The values worth hiding are the resumed session id and + * the system prompt, which carries the agent's workspace identity and runs to + * kilobytes. + */ +function redactArgs(args) { + const out = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + out.push(a); + if ((a === '--resume' || a === '-r') && args[i + 1]) { + out.push(''); + i++; + } else if (a === '--append-system-prompt' && args[i + 1]) { + out.push(''); + i++; + } else if (a === '--openai-api-key' && args[i + 1]) { + out.push('[redacted]'); + i++; + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Credentials and the child environment +// --------------------------------------------------------------------------- + +function firstSet(env, names) { + for (const name of names) { + const v = String((env && env[name]) || '').trim(); + if (v) return v; + } + return ''; +} + +/** + * The key, base URL and model this agent runs with. + * + * The three values come as a SET from one source, never mixed: the agent's own + * form fields (LLM_*) when it has a key there, otherwise OPENAI_* from the + * environment. Mixing them is how a run ends up sending this agent's DashScope + * key to the OpenAI base URL the user exported for another tool. + * + * The workspace's model picker outranks either source's model. + * + * @returns {{apiKey: string, baseUrl: string, model: string, source: 'agent'|'env'|'none'}} + */ +function resolveQwenCredentials(agentEnv = {}, workspaceModel = '') { + const picked = String(workspaceModel || '').trim(); + if (firstSet(agentEnv, ['LLM_API_KEY'])) { + return { + apiKey: firstSet(agentEnv, ['LLM_API_KEY']), + baseUrl: firstSet(agentEnv, ['LLM_BASE_URL']), + model: picked || firstSet(agentEnv, ['LLM_MODEL']), + source: 'agent', + }; + } + if (firstSet(agentEnv, ['OPENAI_API_KEY'])) { + return { + apiKey: firstSet(agentEnv, ['OPENAI_API_KEY']), + baseUrl: firstSet(agentEnv, ['OPENAI_BASE_URL']), + model: picked || firstSet(agentEnv, ['OPENAI_MODEL', 'QWEN_MODEL']), + source: 'env', + }; + } + // No key of ours: the CLI falls back to whatever its own settings.json + // configures (a provider set up with /auth in an interactive session). + return { apiKey: '', baseUrl: '', model: picked, source: 'none' }; +} + +/** + * The environment for one headless run — pure, so the whole auth story is + * testable without spawning anything. + * + * With a key, the OPENAI_* trio is written explicitly, so nothing exported for + * another tool can leak in. A blank base URL becomes Model Studio only for the + * agent's own key: a key that came from OPENAI_* in the environment was + * exported for some other tool, most likely for api.openai.com, and must never + * be sent to a different provider by default — it keeps the CLI's own default. + * Without a key, the environment is left alone for the CLI's own settings. + * + * @param {object} baseEnv the agent's full environment (process env included) + * @param {object} o + * @param {object} o.creds resolveQwenCredentials() output + * @param {string} [o.defaultsPath] system-defaults settings file to load + * @returns {object} a new environment object + */ +function buildQwenChildEnv(baseEnv = {}, { creds = {}, defaultsPath = '' } = {}) { + const env = { ...baseEnv }; + if (creds.apiKey) { + env.OPENAI_API_KEY = creds.apiKey; + const baseUrl = creds.baseUrl || (creds.source === 'agent' ? QWEN_DEFAULT_BASE_URL : ''); + if (baseUrl) env.OPENAI_BASE_URL = baseUrl; + else delete env.OPENAI_BASE_URL; + if (creds.model) env.OPENAI_MODEL = creds.model; + else delete env.OPENAI_MODEL; + } + if (defaultsPath) env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = defaultsPath; + // Approval mode is yolo by design (nobody is at a terminal to approve), and + // the CLI otherwise repeats a warning about it on stderr every run, where it + // would bury a real startup error. + env.QWEN_CODE_SUPPRESS_YOLO_WARNING = '1'; + return env; +} + +// --------------------------------------------------------------------------- +// The system-defaults settings layer +// --------------------------------------------------------------------------- + +/** + * Settings this adapter needs that have no command-line flag. + * + * Loaded as Qwen Code's lowest-precedence "system defaults" scope, so the + * user's own settings.json (and a project's .qwen/settings.json) still win: + * someone who wants auto-memory back turns it on there. + */ +const QWEN_SETTINGS_DEFAULTS = { + memory: { + // The post-turn memory extractor delays every result frame by seconds to + // minutes (see the file header); "dream" is its background consolidation. + enableManagedAutoMemory: false, + enableManagedAutoDream: false, + }, + general: { + // A self-update would swap the CLI — and the stream contract parsed here — + // under a daemon that is mid-run. Upgrades go through the launcher. + enableAutoUpdate: false, + disableAutoUpdate: true, + }, + privacy: { + // The agent is not a person; nothing here should phone home about it. + usageStatisticsEnabled: false, + }, +}; + +function isPlainObject(v) { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function deepMerge(base, over) { + const out = { ...base }; + for (const [k, v] of Object.entries(over || {})) { + out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v; + } + return out; +} + +/** + * The defaults layer to hand the CLI: this adapter's settings under whatever a + * machine-wide system-defaults file already sets, which wins key by key. + */ +function buildQwenSystemDefaults(existing = null) { + return deepMerge(QWEN_SETTINGS_DEFAULTS, isPlainObject(existing) ? existing : {}); +} + +/** + * Where the CLI would read its system-defaults file from, so a machine-wide + * one is merged rather than replaced. Mirrors the CLI's own lookup: an + * explicit QWEN_CODE_SYSTEM_DEFAULTS_PATH, otherwise `system-defaults.json` + * next to the system settings file. + */ +function qwenSystemDefaultsSource(env = {}, platform = process.platform) { + const explicit = String(env.QWEN_CODE_SYSTEM_DEFAULTS_PATH || '').trim(); + if (explicit) return explicit; + const settings = String(env.QWEN_CODE_SYSTEM_SETTINGS_PATH || '').trim() + || (platform === 'darwin' ? '/Library/Application Support/QwenCode/settings.json' + : platform === 'win32' ? 'C:\\ProgramData\\qwen-code\\settings.json' + : '/etc/qwen-code/settings.json'); + const sep = platform === 'win32' ? '\\' : '/'; + const cut = Math.max(settings.lastIndexOf('/'), settings.lastIndexOf('\\')); + return (cut >= 0 ? settings.slice(0, cut) : '.') + sep + 'system-defaults.json'; +} + +// --------------------------------------------------------------------------- +// argv +// --------------------------------------------------------------------------- + +/** + * Built-in tools a workspace agent must not use. + * + * They schedule work inside the CLI's own process — which, in a one-shot + * headless run, exits as soon as the turn ends, so a reminder set this way + * never fires (or keeps the run alive waiting for it). Workspace timers and + * routines, reached over MCP, are the scheduling that outlives the turn. + */ +const EXCLUDED_TOOLS = [ + 'cron_create', + 'cron_list', + 'cron_delete', + 'loop_wakeup', +]; + +/** + * Build the argv for one headless run. + * + * THE PROMPT IS NOT HERE. With no positional query the CLI reads the turn from + * stdin and runs it once (verified), so an arbitrarily long turn — channel + * recap plus the user's message — stays away from the OS argv limit, and a + * quote in a user message cannot become a command-line injection. The key is + * not here either: it travels in the environment, out of the process list. + * + * @param {object} o + * @param {string} [o.appendSystemPrompt] workspace briefing appended to the CLI's own prompt + * @param {string} [o.authType] 'openai' when this agent supplies the key + * @param {string} [o.model] model id (empty = provider default) + * @param {string} [o.resumeSessionId] continue this channel's CLI session + * @param {string} [o.mcpConfigPath] path to the workspace MCP server config + * @param {boolean} [o.planMode] read-only investigation instead of full access + * @returns {string[]} + */ +function buildQwenArgs(o = {}) { + const args = ['--output-format', 'stream-json']; + + // Headless has no one to answer a permission prompt; without yolo the CLI + // silently drops every write and shell tool from the run. Plan mode keeps + // read-only tools only (verified against the init frame's tool list). + args.push('--approval-mode', o.planMode ? 'plan' : 'yolo'); + + // An array option: yargs reads values until the next flag, so another flag + // must always follow it. + args.push('--exclude-tools', ...EXCLUDED_TOOLS); + + const authType = (o.authType || '').trim(); + if (authType) args.push('--auth-type', authType); + + const model = (o.model || '').trim(); + if (model) args.push('--model', model); + + if (o.mcpConfigPath) args.push('--mcp-config', o.mcpConfigPath); + + const resume = (o.resumeSessionId || '').trim(); + if (resume) args.push('--resume', resume); + + const systemPrompt = (o.appendSystemPrompt || '').trim(); + if (systemPrompt) args.push('--append-system-prompt', systemPrompt); + + return args; +} + +// --------------------------------------------------------------------------- +// stream-json frames +// --------------------------------------------------------------------------- + +/** Parse one NDJSON line. Returns null for blank lines and malformed JSON. */ +function parseFrame(line) { + const s = String(line == null ? '' : line).trim(); + if (!s || s[0] !== '{') return null; + try { + const v = JSON.parse(s); + return v && typeof v === 'object' ? v : null; + } catch { + return null; + } +} + +/** + * A short, human-readable preview of a tool call's input, for the channel's + * activity ticker. + */ +function toolInputPreview(input) { + if (input == null) return ''; + if (typeof input !== 'object') return truncate(redactSecrets(String(input)), 150); + const first = input.command || input.file_path || input.absolute_path || input.path + || input.pattern || input.query || input.url || input.prompt || input.description; + if (typeof first === 'string' && first) return truncate(redactSecrets(first), 150); + if (typeof input.content === 'string') return truncate(redactSecrets(input.content), 100); + try { + const json = JSON.stringify(input); + return json === '{}' ? '' : truncate(redactSecrets(json), 150); + } catch { + return ''; + } +} + +/** `mcp__openagents-workspace__workspace_get_history` → `workspace_get_history`. */ +function shortToolName(name) { + const m = String(name || '').match(/^mcp__.+?__(.+)$/); + return m ? m[1] : String(name || ''); +} + +/** + * Describe one tool_use block for the ticker, or null for plumbing. + * + * MCP tools are deferred: the model looks one up with `tool_search` and then + * calls it through the generic `tool_call` wrapper (verified). The lookup is + * not activity worth announcing, and the wrapper is announced as the tool it + * actually ran. + */ +function describeToolUse(block) { + const name = typeof block.name === 'string' ? block.name : ''; + const input = block.input && typeof block.input === 'object' ? block.input : {}; + if (name === 'tool_search') return null; + if (name === 'tool_call' && typeof input.name === 'string' && input.name) { + return { name: shortToolName(input.name), preview: toolInputPreview(input.arguments) }; + } + return { name: shortToolName(name), preview: toolInputPreview(input) }; +} + +// An API failure is streamed as assistant text before the result frame +// reports it. It is the error, not an answer. +const API_ERROR_TEXT_RE = /^\[API Error:/; + +// Frames that carry no user-visible progress. Listing them keeps them out of +// the `unknown` diagnostic channel, so a genuinely new frame type still shows +// up in the log instead of being lost in the noise. `stream_event` carries the +// per-run goal_state ping (and partial deltas, which are not requested). +const IGNORED_FRAMES = new Set(['user', 'stream_event']); + +/** + * Normalize one parsed frame into something the adapter can act on. + * + * @returns {{kind: string, [k: string]: any}} + */ +function interpretQwenFrame(frame) { + if (!frame || typeof frame !== 'object') return { kind: 'ignored' }; + + if (frame.type === 'result') { + const err = frame.error && typeof frame.error === 'object' ? frame.error : null; + return { + kind: 'result', + subtype: typeof frame.subtype === 'string' ? frame.subtype : '', + isError: frame.is_error === true, + // `result` is present on success and absent on failure; always a string + // here so callers never have to guard it. + text: typeof frame.result === 'string' ? frame.result : '', + errorMessage: err && typeof err.message === 'string' ? redactSecrets(err.message) : '', + sessionId: typeof frame.session_id === 'string' ? frame.session_id : null, + numTurns: Number.isFinite(frame.num_turns) ? frame.num_turns : null, + durationMs: Number.isFinite(frame.duration_ms) ? frame.duration_ms : null, + }; + } + + if (frame.type === 'system') { + if (frame.subtype === 'init') { + return { + kind: 'init', + sessionId: typeof frame.session_id === 'string' ? frame.session_id : null, + model: typeof frame.model === 'string' ? frame.model : '', + permissionMode: typeof frame.permission_mode === 'string' ? frame.permission_mode : '', + mcpServers: Array.isArray(frame.mcp_servers) + ? frame.mcp_servers.filter((s) => s && typeof s === 'object') + : [], + version: typeof frame.qwen_code_version === 'string' ? frame.qwen_code_version : '', + }; + } + // Compaction is the one system message worth showing: it is slow, and a + // silent channel during it reads as a hang. + const message = typeof frame.message === 'string' ? frame.message : ''; + const subtype = typeof frame.subtype === 'string' ? frame.subtype : ''; + if (subtype.includes('compact') || /compact/i.test(message)) { + return { kind: 'status', text: message || 'Compacting the conversation...' }; + } + return { kind: 'ignored' }; + } + + if (frame.type === 'assistant') { + const blocks = (frame.message && Array.isArray(frame.message.content)) + ? frame.message.content : []; + const texts = []; + const tools = []; + const apiErrors = []; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + const text = block.text.trim(); + if (API_ERROR_TEXT_RE.test(text)) apiErrors.push(redactSecrets(text)); + else texts.push(text); + } else if (block.type === 'tool_use') { + const tool = describeToolUse(block); + if (tool) tools.push(tool); + } + // `thinking` blocks are the model's private reasoning; the Claude + // adapter does not post them either. + } + if (!texts.length && !tools.length && !apiErrors.length) return { kind: 'ignored' }; + return { kind: 'assistant', texts, tools, apiErrors }; + } + + if (IGNORED_FRAMES.has(frame.type)) return { kind: 'ignored' }; + + return { kind: 'unknown', raw: truncate(redactSecrets(String(frame.type || '')), 80) }; +} + +// --------------------------------------------------------------------------- +// Run classification +// --------------------------------------------------------------------------- + +const CODING_PLAN_HINT = 'Model Studio (DashScope) keys and Coding Plan keys are not interchangeable: ' + + 'a Coding Plan key needs https://coding.dashscope.aliyuncs.com/v1 (international: ' + + 'https://coding-intl.dashscope.aliyuncs.com/v1).'; + +/** + * What a run's error message means for the user, first match wins. Matched on + * the CLI's own `error.message`, which carries the provider's status code and + * text verbatim ("[API Error: 401 Incorrect API key provided…]"). + */ +const ERROR_RULES = [ + { + kind: 'not_configured', + re: /No auth type is selected/i, + message: 'Qwen Code has no model credentials. Set an API key for this agent (and a base URL and ' + + 'model if you are not using Model Studio) — the default base URL is ' + + `${QWEN_DEFAULT_BASE_URL}. ${CODING_PLAN_HINT}`, + detail: false, + }, + { + kind: 'auth', + re: /\b(401|403)\b|incorrect api key|invalid (access )?token|invalid api[ _-]?key|unauthori[sz]ed|token expired/i, + message: `The model endpoint rejected the API key. Check the key, and that it belongs to the base URL's service. ${CODING_PLAN_HINT}`, + detail: true, + }, + { + kind: 'model', + re: /\b404\b|model\b[^.]*\b(does not exist|not found|not exist|unsupported)|model_not_found/i, + message: 'The endpoint does not serve the configured model. Pick a model the base URL offers.', + detail: true, + }, + { + kind: 'quota', + re: /\b429\b|rate.?limit|quota|insufficient|arrearage|exceeded your/i, + message: 'The model endpoint is rate limiting this key or the account is out of quota. Wait, or top the account up, then retry.', + detail: true, + }, + { + kind: 'network', + re: /connection error|fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|certificate|timed? ?out/i, + message: 'Qwen Code could not reach the model endpoint. Check the base URL, the network, and any proxy settings.', + detail: true, + }, + { + kind: 'model_service', + re: /\b5\d\d\b/, + message: 'The model service returned a server error. This is usually temporary — retry shortly.', + detail: true, + }, +]; + +function messageFor(text) { + const raw = String(text || '').trim(); + const detail = truncate(redactSecrets(raw.replace(/^\[API Error:\s*/i, '').replace(/\]$/, '')), 300); + for (const rule of ERROR_RULES) { + if (rule.re.test(raw)) { + return { + kind: rule.kind, + userMessage: rule.detail && detail ? `${rule.message} (${detail})` : rule.message, + }; + } + } + return null; +} + +// What the CLI prints, on stderr only, when `--resume` names a session it does +// not have — pruned, or recorded under a different working directory. +const STALE_SESSION_RE = /No saved session found/i; + +/** + * Classify how a run ended. + * + * The result frame outranks the exit code: it is present on every model and + * auth failure and says what went wrong. The exit code and stderr are read only + * when no result frame arrived, which is the stopped / crashed / refused-to- + * start case. + * + * @param {object} o + * @param {object|null} [o.result] the interpreted result frame, when one arrived + * @param {number|null} [o.code] process exit code + * @param {string|null} [o.signal] terminating signal, if any + * @param {string} [o.stderr] captured stderr + * @param {string[]} [o.apiErrors] API error texts streamed before the result + * @returns {{kind: string, ok: boolean, userMessage: string|null}} + */ +function classifyQwenRun({ result = null, code = null, signal = null, stderr = '', apiErrors = [] } = {}) { + if (result && !result.isError) { + return { kind: 'success', ok: true, userMessage: null }; + } + + if (result && result.isError) { + const text = result.errorMessage || (apiErrors && apiErrors[0]) || ''; + const known = messageFor(text); + if (known) return { ...known, ok: false }; + return { + kind: 'run_error', + ok: false, + userMessage: text + ? `Qwen Code could not finish the run: ${truncate(redactSecrets(text), 400)}` + : 'Qwen Code could not finish the run. Check the daemon log for details, then retry.', + }; + } + + // No result frame from here down. + if (code === 53) { + return { kind: 'turn_limit', ok: false, userMessage: 'Qwen Code stopped at its turn limit before finishing.' }; + } + if (code === 55) { + return { kind: 'budget', ok: false, userMessage: 'Qwen Code stopped at its time or tool-call budget before finishing.' }; + } + if (signal || code === 130) { + return { kind: 'interrupted', ok: false, userMessage: 'The run was interrupted.' }; + } + + const err = String(stderr || '').trim(); + if (STALE_SESSION_RE.test(err)) { + return { kind: 'stale_session', ok: false, userMessage: 'The saved Qwen Code session could not be resumed.' }; + } + const streamed = apiErrors && apiErrors[0]; + if (streamed) { + const known = messageFor(streamed); + if (known) return { ...known, ok: false }; + } + if (err) { + const firstLine = err.split('\n').map((l) => l.trim()).find(Boolean) || err; + return { + kind: 'startup_error', + ok: false, + userMessage: `Qwen Code stopped before answering: ${truncate(redactSecrets(firstLine), 400)}`, + }; + } + + return { + kind: 'no_result', + ok: false, + userMessage: `Qwen Code exited without answering (code ${code}). Check the daemon log, then retry.`, + }; +} + +module.exports = { + QWEN_MIN_VERSION, + QWEN_TESTED_MAX_VERSION, + QWEN_DEFAULT_BASE_URL, + QWEN_SETTINGS_DEFAULTS, + EXCLUDED_TOOLS, + ERROR_RULES, + parseQwenVersion, + compareVersions, + classifyQwenVersion, + redactSecrets, + redactArgs, + truncate, + resolveQwenCredentials, + buildQwenChildEnv, + buildQwenSystemDefaults, + qwenSystemDefaultsSource, + buildQwenArgs, + parseFrame, + toolInputPreview, + shortToolName, + describeToolUse, + interpretQwenFrame, + classifyQwenRun, +}; diff --git a/packages/agent-connector/src/adapters/qwen-code.js b/packages/agent-connector/src/adapters/qwen-code.js new file mode 100644 index 000000000..84a955845 --- /dev/null +++ b/packages/agent-connector/src/adapters/qwen-code.js @@ -0,0 +1,1043 @@ +/** + * Qwen Code adapter for OpenAgents workspace. + * + * Bridges Alibaba's Qwen Code CLI (`npm i -g @qwen-code/qwen-code`, binary + * `qwen`) to an OpenAgents workspace: + * - polling loop + per-channel task dispatch (inherited from BaseAdapter) + * - one headless `qwen --output-format stream-json` run per user message, + * prompt piped over stdin; stopping the run stops the task + * - workspace tools over MCP, the same stdio server the Claude adapter wires + * up, pointed at with `--mcp-config` + * - session continuity per channel: the CLI's `session_id` comes back on the + * init frame and is replayed with `--resume` on the channel's next turn + * + * Four CLI-specific decisions are worth knowing before editing this file + * (the verified facts behind them are in qwen-code-stream.js): + * + * AUTO-MEMORY IS OFF. Left on, a memory-extractor subagent runs after every + * turn and the result frame waits for it — minutes, on a long session. The + * adapter loads a "system defaults" settings layer that turns it off; the + * user's own ~/.qwen/settings.json still outranks it. + * + * THE KEY IS THE AGENT'S, AS A SET. Key, base URL and model come together + * from the agent's form (LLM_*) or together from OPENAI_*, and `--auth-type + * openai` is passed whenever there is a key. Without one, the CLI's own + * settings decide, so a provider configured with /auth keeps working. + * + * SESSIONS BELONG TO A DIRECTORY. The CLI files a transcript under the + * working directory it ran in, and `--resume` from anywhere else fails. The + * channel's session is remembered together with its directory. + * + * ON WINDOWS THE CLI IS TWO PROCESSES. Its entry script relaunches itself as + * a child (`node --expose-gc cli.js`), and killing the parent — which is all + * `ChildProcess.kill()` does there — leaves that child running the turn. Stop + * therefore goes straight to `taskkill /T`. + * + * Verified end to end against @qwen-code/qwen-code 0.25.1. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +// spawn() here is the WSL bridge from ../wsl: same signature as +// child_process.spawn, and a straight pass-through unless the resolved CLI +// lives on the other side of the Windows/WSL boundary. +const { spawn } = require('../wsl'); + +const BaseAdapter = require('./base'); +const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils'); +const { buildClaudeSystemPrompt } = require('./workspace-prompt'); +const { defaultAgentWorkdir, whichBinary, whereBinary, getEnhancedEnv } = require('../paths'); +const { + buildQwenArgs, + buildQwenChildEnv, + buildQwenSystemDefaults, + classifyQwenRun, + classifyQwenVersion, + interpretQwenFrame, + parseFrame, + qwenSystemDefaultsSource, + redactArgs, + redactSecrets, + resolveQwenCredentials, + truncate, + QWEN_MIN_VERSION, +} = require('./qwen-code-stream'); + +const IS_WINDOWS = process.platform === 'win32'; + +// Idle watchdog: with no stdout frame for this long we nudge the channel, and +// after MAX consecutive silences we kill a run that is probably wedged. +const WATCHDOG_INTERVAL_MS = 15000; +const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working" +const WATCHDOG_MAX = 20; // ~5 min of silence → kill + +// The CLI's own floor (`engines.node` in its package.json), so an interpreter +// under it cannot run the agent at all. +const MIN_NODE_MAJOR = 22; + +// Cache window for the version probe, so preflight never spawns `--version` +// more than once per window across channels. +const VERSION_PROBE_TTL_MS = 60000; + +// Grace periods for a stop. The CLI answers SIGINT within ~100ms (it cancels +// the turn and kills its own shell tools), so escalation is for a wedged run. +const STOP_GRACE_MS = 3000; + +const INSTALL_HINT = 'npm install -g @qwen-code/qwen-code'; +const PACKAGE_DIR = ['@qwen-code', 'qwen-code']; + +class QwenCodeAdapter extends BaseAdapter { + /** + * @param {object} opts - BaseAdapter opts plus: + * @param {Set} [opts.disabledModules] + * @param {string} [opts.workingDir] + */ + constructor(opts) { + super(opts); + this.disabledModules = opts.disabledModules || new Set(); + // channel → { sessionId, workingDir } + this._channelSessions = {}; + // channel → child process (the in-flight headless run) + this._channelProcesses = {}; + this._stoppingChannels = new Set(); + this._sessionsFile = path.join( + os.homedir(), '.openagents', 'sessions', + `${this.workspaceId}_${this.agentName}_qwen-code.json`, + ); + this._isWindows = IS_WINDOWS; + this._versionProbe = null; // { at, bin, version, supported } + this._loadSessions(); + } + + /** + * The model this agent actually runs — exactly what is passed to the CLI, + * so the workspace never shows a model the run did not use. + */ + modelLabel() { + return resolveQwenCredentials(this.agentEnv, this.workspaceModel).model || null; + } + + // ------------------------------------------------------------------ + // Session persistence (Qwen Code session ids, bound to working dir) + // ------------------------------------------------------------------ + + _loadSessions() { + try { + if (fs.existsSync(this._sessionsFile)) { + const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8')); + if (data && typeof data === 'object') { + Object.assign(this._channelSessions, data); + this._log(`Loaded ${Object.keys(data).length} Qwen Code session(s)`); + } + } + } catch { + this._log('Could not load Qwen Code sessions file, starting fresh'); + } + } + + _saveSessions() { + try { + fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true }); + fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions)); + } catch {} + } + + /** + * A resumable session id for this channel, or null. + * + * Qwen Code stores a transcript under the directory it ran in + * (~/.qwen/projects//chats), so a saved id from a different directory + * would not resolve — resume only when the directory still matches. + */ + _resumableSession(channel, workingDir) { + const entry = this._channelSessions[channel]; + if (!entry || !entry.sessionId) return null; + if (entry.workingDir && workingDir && entry.workingDir !== workingDir) return null; + return entry.sessionId; + } + + _rememberSession(channel, sessionId, workingDir) { + if (!sessionId) return; + const prev = this._channelSessions[channel]; + if (prev && prev.sessionId === sessionId && prev.workingDir === workingDir) return; + this._channelSessions[channel] = { sessionId, workingDir }; + this._saveSessions(); + } + + _clearSession(channel) { + if (this._channelSessions[channel]) { + delete this._channelSessions[channel]; + this._saveSessions(); + } + } + + // ------------------------------------------------------------------ + // Shutdown + // ------------------------------------------------------------------ + + stop() { + super.stop(); + void this._stopAllProcesses('Agent stopped.'); + } + + async _stopAllProcesses(message = 'Execution stopped.') { + const entries = Object.entries(this._channelProcesses); + if (!entries.length) return; + this._log(`Stopping ${entries.length} running Qwen Code process(es)...`); + for (const [channel, proc] of entries) { + this._stoppingChannels.add(channel); + await this._stopProcess(proc); + delete this._channelProcesses[channel]; + delete this._channelQueues[channel]; + try { await this.sendResponse(channel, message); } catch {} + } + } + + /** + * Stop a run. Resolves true once the process has exited, false when that + * could not be confirmed. + * + * POSIX: SIGINT to the process group, which the CLI handles by cancelling the + * turn and killing its own shell tools (they run in a PTY session of their + * own, outside our group), then SIGTERM and SIGKILL for a wedged run. + * + * Windows: `taskkill /T /F` straight away. There is no SIGINT to send — Node + * maps every kill() to TerminateProcess — and terminating only the entry + * process would orphan the child it relaunched the CLI in (file header). + */ + async _stopProcess(proc) { + if (!proc || proc.exitCode !== null || proc.signalCode) return true; + try { + if (this._isWindows) { + this._killTree(proc.pid); + let exited = await this._waitExit(proc, STOP_GRACE_MS); + if (!exited) { + try { proc.kill(); } catch {} + exited = await this._waitExit(proc, 1000); + } + return exited; + } + const signalGroup = (sig) => { + try { process.kill(-proc.pid, sig); } catch { try { proc.kill(sig); } catch {} } + }; + signalGroup('SIGINT'); + let exited = await this._waitExit(proc, STOP_GRACE_MS); + if (!exited) { + signalGroup('SIGTERM'); + exited = await this._waitExit(proc, 1500); + } + if (!exited) { + signalGroup('SIGKILL'); + exited = await this._waitExit(proc, 1000); + } + return exited; + } catch { + return false; + } + } + + /** Kill a Windows process tree. Isolated for testing. */ + _killTree(pid) { + try { + execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { timeout: 5000, windowsHide: true }); + } catch {} + } + + _waitExit(proc, ms) { + return new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode) { resolve(true); return; } + const t = setTimeout(() => resolve(false), ms); + proc.once('exit', () => { clearTimeout(t); resolve(true); }); + }); + } + + // ------------------------------------------------------------------ + // Binary resolution (cross-platform) + // ------------------------------------------------------------------ + + /** + * Node interpreter used to execute a JS entry point. + * + * Prefer the launcher's managed runtime, and fall back to the interpreter + * running this daemon when it clears `minMajor` (by default the CLI's own + * floor). Returning null means "no suitable Node" and the caller spawns the + * shim directly instead of guessing. + */ + _findNodeBin(minMajor = MIN_NODE_MAJOR) { + const home = os.homedir(); + const candidates = this._isWindows + ? [path.join(home, '.openagents', 'nodejs', 'node.exe')] + : [path.join(home, '.openagents', 'nodejs', 'node'), + path.join(home, '.openagents', 'nodejs', 'bin', 'node')]; + for (const c of candidates) { + if (fs.existsSync(c) && (!minMajor || this._nodeMajor(c) >= minMajor)) return c; + } + const ownMajor = parseInt(String(process.versions.node).split('.')[0], 10) || 0; + return ownMajor >= minMajor ? process.execPath : null; + } + + /** Major version of a Node binary, or 0 when it cannot be read. */ + _nodeMajor(nodeBin) { + try { + const out = execFileSync(nodeBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true }); + const m = String(out).match(/v(\d+)\./); + return m ? parseInt(m[1], 10) : 0; + } catch { + return 0; + } + } + + /** + * Locate the Qwen Code CLI. + * + * The isolated runtime prefix is checked first so the version the launcher + * installed wins over anything else on PATH. Tier 3 covers the official + * standalone installer (install-qwen-standalone.sh / .ps1), which bundles + * its own Node and puts a wrapper in ~/.local/bin or + * %LOCALAPPDATA%\qwen-code\bin. + */ + _findQwenBinary() { + const home = os.homedir(); + const ext = this._isWindows ? '.cmd' : ''; + + const prefixes = [ + path.join(home, '.openagents', 'runtimes', 'qwen-code', 'node_modules'), + path.join(home, '.openagents', 'nodejs', 'node_modules'), + ]; + + // Tier 0: npm shim inside an OpenAgents-managed prefix. + for (const root of prefixes) { + const c = path.join(root, '.bin', `qwen${ext}`); + if (fs.existsSync(c)) return c; + } + + // Tier 0b: the package's own entry point, run under Node directly. + for (const root of prefixes) { + const entry = path.join(root, ...PACKAGE_DIR, 'cli-entry.js'); + if (fs.existsSync(entry)) return entry; + } + + // Tier 1: PATH, via the codepage-safe lookup (a non-ASCII username would + // otherwise be mangled into an ENOENT) using the enriched PATH. + const viaWhere = whereBinary('qwen'); + if (viaWhere) return viaWhere; + + // Tier 2: next to the current Node interpreter (npm global installs). + const nearNode = path.join(path.dirname(process.execPath), `qwen${ext}`); + if (fs.existsSync(nearNode)) return nearNode; + + // Tier 3: common install locations, the standalone installer's included. + const env = process.env; + const candidates = []; + if (env.QWEN_INSTALL_BIN_DIR) candidates.push(path.join(env.QWEN_INSTALL_BIN_DIR, `qwen${ext}`)); + if (env.QWEN_INSTALL_ROOT) candidates.push(path.join(env.QWEN_INSTALL_ROOT, 'bin', `qwen${ext}`)); + if (this._isWindows) { + candidates.push( + path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'qwen-code', 'bin', 'qwen.cmd'), + path.join(env.APPDATA || '', 'npm', 'qwen.cmd'), + ); + } else { + candidates.push( + path.join(home, '.local', 'bin', 'qwen'), + path.join(home, '.npm-global', 'bin', 'qwen'), + '/opt/homebrew/bin/qwen', + '/usr/local/bin/qwen', + ); + } + for (const c of candidates) if (fs.existsSync(c)) return c; + + // Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…). + return whichBinary('qwen') || null; + } + + /** + * True when `dir` is a standalone install: the release archive's layout, + * with its manifest, its bundled Node, and the CLI's entry script. + */ + _isStandaloneRoot(dir) { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8')); + if (!manifest || manifest.name !== '@qwen-code/qwen-code') return false; + } catch { + return false; + } + return fs.existsSync(path.join(dir, 'lib', 'cli-entry.js')) + && fs.existsSync(this._standaloneNode(dir)); + } + + _standaloneNode(dir) { + return this._isWindows + ? path.join(dir, 'node', 'node.exe') + : path.join(dir, 'node', 'bin', 'node'); + } + + /** + * The standalone install a binary path belongs to, or null. + * + * The path is either the archive's own launcher (/bin/qwen[.cmd]) or + * the installer's one-line wrapper on PATH, which names that launcher by + * absolute path (`exec '/bin/qwen' "$@"` / `call "\bin\qwen.cmd" %*`). + */ + _findStandaloneRoot(binPath) { + const direct = path.resolve(path.dirname(binPath), '..'); + if (this._isStandaloneRoot(direct)) return direct; + try { + if (fs.statSync(binPath).size > 4096) return null; + const text = fs.readFileSync(binPath, 'utf-8'); + const m = text.match(/(["'])([^"'\r\n]+?[\\/]bin[\\/]qwen(?:\.cmd)?)\1/i); + if (m) { + const root = path.resolve(path.dirname(m[2]), '..'); + if (this._isStandaloneRoot(root)) return root; + } + } catch {} + return null; + } + + /** + * Resolve a shim to [nodeBin, jsEntry] so the JS entry point runs directly. + * Returns null when no suitable Node exists or the target isn't a JS entry, + * leaving the caller to spawn the shim as-is. + */ + _resolveToNodeCmd(binPath, minMajor = MIN_NODE_MAJOR) { + if (this._isWindows && binPath.toLowerCase().endsWith('.cmd')) { + try { + const cmdDir = path.dirname(path.resolve(binPath)); + const content = fs.readFileSync(binPath, 'utf-8'); + const jsMatch = content.match(/%dp0%\\([^\s"*?]+\.m?js)/i); + if (jsMatch) { + const target = path.resolve(cmdDir, jsMatch[1]); + const nodeBin = fs.existsSync(target) ? this._findNodeBin(minMajor) : null; + if (nodeBin) return [nodeBin, target]; + } + } catch {} + return null; + } + + if (binPath.endsWith('.mjs') || binPath.endsWith('.js')) { + const nodeBin = this._findNodeBin(minMajor); + return nodeBin ? [nodeBin, binPath] : null; + } + + try { + let target = binPath; + if (fs.lstatSync(binPath).isSymbolicLink()) { + target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath)); + } + if (target.endsWith('.mjs') || target.endsWith('.js') || this._isNodeShebangScript(target)) { + const nodeBin = this._findNodeBin(minMajor); + return nodeBin ? [nodeBin, target] : null; + } + } catch {} + return null; + } + + /** True when a file begins with a `#!...node` shebang. */ + _isNodeShebangScript(filePath) { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(64); + const n = fs.readSync(fd, buf, 0, 64, 0); + const head = buf.slice(0, n).toString('utf-8'); + return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]); + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } + } + + /** + * How to spawn the CLI: { command, args, env } where env is an overlay. + * + * Every route ends at the CLI's JS entry under a Node that can run it, + * because a Windows .cmd shim would put the whole command line — the + * kilobytes-long system prompt included — through cmd.exe and its + * 8191-character limit: + * - a standalone install runs on its own bundled Node, with the launcher + * path its shim would have exported; + * - an npm install runs on the launcher's Node (>= 22); + * - anything else is spawned as found. + */ + _spawnPlan(binPath, args) { + const root = this._findStandaloneRoot(binPath); + if (root) { + return { + command: this._standaloneNode(root), + args: [path.join(root, 'lib', 'cli-entry.js'), ...args], + env: { QWEN_CODE_LAUNCHER_PATH: path.join(root, 'bin', this._isWindows ? 'qwen.cmd' : 'qwen') }, + }; + } + const resolved = this._resolveToNodeCmd(binPath); + if (resolved) return { command: resolved[0], args: [resolved[1], ...args], env: {} }; + if (this._isWindows && binPath.toLowerCase().endsWith('.cmd')) { + return { command: 'cmd.exe', args: ['/c', binPath, ...args], env: {} }; + } + return { command: binPath, args, env: {} }; + } + + // ------------------------------------------------------------------ + // Version preflight (cached; HARD minimum QWEN_MIN_VERSION) + // ------------------------------------------------------------------ + + /** Run `--version` and return raw output. Isolated for testing. */ + _readVersionRaw(bin) { + try { + const plan = this._spawnPlan(bin, ['--version']); + return execFileSync(plan.command, plan.args, { + encoding: 'utf-8', + timeout: 15000, + windowsHide: true, + env: { ...getEnhancedEnv(this.agentEnv), ...plan.env }, + }); + } catch (e) { + // A CLI that errors on --version still tells us its version sometimes. + return (e && (e.stdout || e.stderr)) ? String(e.stdout || e.stderr) : ''; + } + } + + /** Cached version verdict for a resolved binary. */ + _checkVersion(bin) { + const now = Date.now(); + if (this._versionProbe && this._versionProbe.bin === bin && now - this._versionProbe.at < VERSION_PROBE_TTL_MS) { + return this._versionProbe; + } + const verdict = classifyQwenVersion(this._readVersionRaw(bin)); + this._versionProbe = { ...verdict, bin, at: now }; + return this._versionProbe; + } + + // ------------------------------------------------------------------ + // Settings layer + // ------------------------------------------------------------------ + + /** + * Write the system-defaults settings file this run loads, and return its + * path — or null to leave the CLI's own lookup alone. + * + * A machine-wide system-defaults file is merged in (and wins) rather than + * replaced. One that exists but cannot be read is left in charge: the + * adapter's settings are worth less than an administrator's. + */ + _writeSystemDefaults() { + const target = path.join(os.homedir(), '.openagents', 'qwen-code', 'system-defaults.json'); + try { + let existing = null; + const source = qwenSystemDefaultsSource(this.agentEnv, process.platform); + if (source && path.resolve(source) !== target && fs.existsSync(source)) { + try { + existing = JSON.parse(fs.readFileSync(source, 'utf-8')); + } catch { + this._log(`Could not parse ${source}; leaving Qwen Code's system defaults as they are`); + return null; + } + } + const content = JSON.stringify(buildQwenSystemDefaults(existing), null, 2); + let current = null; + try { current = fs.readFileSync(target, 'utf-8'); } catch {} + if (current !== content) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + // Atomic replace: another channel's run may be reading it right now. + const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, target); + } + return target; + } catch (e) { + this._log(`Could not write the Qwen Code settings layer: ${redactSecrets(e && e.message)}`); + return null; + } + } + + // ------------------------------------------------------------------ + // Workspace MCP server + // ------------------------------------------------------------------ + + /** + * Write the MCP config that gives this run the workspace_* tools, and return + * its path (or null when the server entry point cannot be located — the run + * still proceeds, just without workspace tools). + * + * The file carries the workspace token, so it is written 0600 into an + * OpenAgents-owned directory rather than the user's project or their + * ~/.qwen config. The caller deletes it when the run ends. + */ + _writeMcpConfig(channel) { + try { + const mcpArgs = [ + 'mcp-server', + '--workspace-id', this.workspaceId, + '--channel-name', channel, + '--agent-name', this.agentName, + '--endpoint', this.endpoint, + ]; + if (this.disabledModules.has('files')) mcpArgs.push('--disable-files'); + if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser'); + if (this.disabledModules.has('knowledge')) mcpArgs.push('--disable-knowledge'); + + const resolved = this._resolveMcpServerCmd(mcpArgs); + if (!resolved) { + this._log('Could not find the openagents binary — workspace MCP tools are unavailable this run'); + return null; + } + + const config = { + mcpServers: { + 'openagents-workspace': { + command: resolved.command, + args: resolved.args, + env: { OA_WORKSPACE_TOKEN: this.token }, + }, + }, + }; + + const dir = path.join(os.homedir(), '.openagents', 'mcp-configs'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `qwen-code-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, JSON.stringify(config), { mode: 0o600 }); + try { fs.chmodSync(file, 0o600); } catch {} + return file; + } catch (e) { + this._log(`Could not write the MCP config: ${redactSecrets(e && e.message)}`); + return null; + } + } + + /** + * Locate this package's own CLI, which hosts the workspace MCP server. + * Returns { command, args } or null. + */ + _resolveMcpServerCmd(mcpArgs) { + const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js'); + if (fs.existsSync(siblingBin)) { + // The MCP server is this package's own code: any Node that runs the + // daemon runs it, so the CLI's newer floor does not apply. + return { command: this._findNodeBin(0), args: [siblingBin, ...mcpArgs] }; + } + + const home = os.homedir(); + const ext = this._isWindows ? '.cmd' : ''; + const candidates = []; + try { + const runtimesRoot = path.join(home, '.openagents', 'runtimes'); + for (const d of fs.readdirSync(runtimesRoot, { withFileTypes: true })) { + if (d.isDirectory()) { + candidates.push(path.join(runtimesRoot, d.name, 'node_modules', '.bin', `openagents${ext}`)); + } + } + } catch {} + candidates.push(path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${ext}`)); + + let bin = candidates.find((c) => fs.existsSync(c)) || null; + if (!bin) bin = whereBinary('openagents'); + if (!bin) return null; + + const resolved = this._resolveToNodeCmd(bin, 0); + if (resolved) return { command: resolved[0], args: [...resolved.slice(1), ...mcpArgs] }; + return { command: bin, args: mcpArgs }; + } + + // ------------------------------------------------------------------ + // Prompt assembly + // ------------------------------------------------------------------ + + /** + * Short transcript of recent channel chat, used to re-seed a fresh session. + * Only needed when there is no session to resume — the CLI already holds the + * history in that case. + */ + async _buildChannelRecap(channel, currentMessage) { + try { + const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30); + if (!messages || messages.length === 0) return null; + const lines = []; + for (const m of messages) { + const mt = m.messageType || 'chat'; + if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue; + const text = (m.content || '').trim(); + if (!text || text === currentMessage) continue; + const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent'); + lines.push(`[${who}] ${truncate(text, 800)}`); + } + if (lines.length === 0) return null; + return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n'); + } catch { + return null; + } + } + + _dirExists(p) { + try { return fs.statSync(p).isDirectory(); } catch { return false; } + } + + // ------------------------------------------------------------------ + // Run + // ------------------------------------------------------------------ + + /** + * Run one headless turn and consume its stream-json output. + * + * The prompt goes in over stdin; the reply comes off the single `result` + * frame. Assistant frames only drive the live ticker, so a change to the + * streaming shape costs a progress update, never the answer. + * + * @returns {Promise} { code, signal, result, sessionId, anyOutput, + * lastAssistantText, apiErrors, userStopped, stderr } + */ + _runQwen(channel, bin, args, workingDir, prompt, env = {}) { + // Stopped while this turn was being prepared: start nothing (no tokens). + if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true }); + return new Promise((resolve) => { + const plan = this._spawnPlan(bin, args); + this._log(`Spawning: ${path.basename(plan.command)} ${redactArgs(plan.args).join(' ')} (cwd=${workingDir})`); + + let proc; + try { + proc = spawn(plan.command, plan.args, { + cwd: workingDir, + env: { ...env, ...plan.env }, + stdio: ['pipe', 'pipe', 'pipe'], + // Own process group, so a stop reaches the CLI and anything it left + // in our group. + detached: !this._isWindows, + windowsHide: true, + }); + } catch (e) { + resolve({ code: null, signal: null, result: null, sessionId: null, anyOutput: false, userStopped: false, spawnError: e && e.message }); + return; + } + + this._channelProcesses[channel] = proc; + + let settled = false; + let stdoutBuf = ''; + let stderrBuf = ''; + let anyOutput = false; + let result = null; + let sessionId = null; + // The most recent assistant text, kept as a fallback answer for a clean + // run whose result frame came back empty. + let lastAssistantText = ''; + const apiErrors = []; + let lastFrameAt = Date.now(); + let silences = 0; + let lastToolLabel = null; + let killedByWatchdog = false; + let exitInfo = null; + let closeFallback = null; + + const finish = (payload) => { + if (settled) return; + settled = true; + clearInterval(watchdog); + if (closeFallback) clearTimeout(closeFallback); + if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel]; + resolve(payload); + }; + + const onFrame = (line) => { + const frame = parseFrame(line); + if (!frame) return; + lastFrameAt = Date.now(); + silences = 0; + anyOutput = true; + + const ev = interpretQwenFrame(frame); + if (ev.kind === 'result') { + result = ev; + if (ev.sessionId) sessionId = ev.sessionId; + return; + } + if (ev.kind === 'init') { + // The id arrives here, BEFORE any model call — which is what keeps a + // channel resumable even when the turn itself fails. + if (ev.sessionId) sessionId = ev.sessionId; + for (const s of ev.mcpServers) { + if (s.status && s.status !== 'connected') { + this._log(`MCP server ${s.name} is ${s.status} — its tools are unavailable this run`); + } + } + return; + } + if (ev.kind === 'assistant') { + apiErrors.push(...ev.apiErrors); + if (ev.texts.length) lastAssistantText = ev.texts.join('\n'); + for (const text of ev.texts) { + void this.sendThinking(channel, text).catch(() => {}); + } + for (const tool of ev.tools) { + // Only announce a CHANGE of activity: a run touching thirty files + // would otherwise post thirty near-identical status lines. + const label = tool.preview ? `${tool.name} › ${tool.preview}` : tool.name; + if (label && label !== lastToolLabel) { + lastToolLabel = label; + void this.sendStatus(channel, label).catch(() => {}); + } + } + return; + } + if (ev.kind === 'status') { + void this.sendStatus(channel, ev.text).catch(() => {}); + return; + } + if (ev.kind === 'unknown') { + this._log(`Unrecognized frame (ignored): ${ev.raw}`); + } + }; + + const done = () => { + // Runs once: 'close' can still arrive after the fallback timer ended + // the turn, by which time this channel may be on its next turn. + if (settled) return; + // Flush a trailing line with no newline (the result frame, if the CLI + // exits without one). + if (stdoutBuf.trim()) { try { onFrame(stdoutBuf); } catch {} stdoutBuf = ''; } + const userStopped = this._stoppingChannels.has(channel); + this._stoppingChannels.delete(channel); + const { code = null, signal = null } = exitInfo || {}; + finish({ + code, + signal: killedByWatchdog ? 'SIGKILL' : signal, + result, + sessionId, + anyOutput, + lastAssistantText, + apiErrors, + userStopped, + stderr: redactSecrets(stderrBuf), + }); + }; + + // A stopped or killed child's pipes can emit 'error' (EPIPE/ECONNRESET, + // seen on macOS); unhandled, that would take the daemon down. + proc.stdout.on('error', () => {}); + proc.stderr.on('error', () => {}); + + proc.stdout.on('data', (chunk) => { + stdoutBuf += chunk.toString('utf-8'); + let nl; + while ((nl = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, nl); + stdoutBuf = stdoutBuf.slice(nl + 1); + try { onFrame(line); } catch (e) { this._log(`Frame handling failed: ${redactSecrets(e && e.message)}`); } + } + }); + + proc.stderr.on('data', (chunk) => { + // Bounded: a long run must not grow it without limit. + stderrBuf = truncate(stderrBuf + chunk.toString('utf-8'), 8000); + }); + + proc.on('error', (e) => { + finish({ code: null, signal: null, result, sessionId, anyOutput, lastAssistantText, apiErrors, userStopped: false, spawnError: e && e.message }); + }); + + proc.on('exit', (code, signal) => { + exitInfo = { code, signal }; + // 'close' waits for every holder of the pipes. Something the CLI left + // running could keep them open, and the turn would never end. + closeFallback = setTimeout(done, 2000); + }); + + proc.on('close', (code, signal) => { + if (!exitInfo) exitInfo = { code, signal }; + done(); + }); + + const watchdog = setInterval(() => { + if (Date.now() - lastFrameAt < WATCHDOG_INTERVAL_MS) return; + silences += 1; + if (silences === WATCHDOG_NUDGE_AT) { + void this.sendStatus(channel, 'still working...').catch(() => {}); + } + if (silences >= WATCHDOG_MAX) { + this._log('No output for ~5 minutes — killing a wedged run'); + killedByWatchdog = true; + void this._stopProcess(proc); + } + }, WATCHDOG_INTERVAL_MS); + + try { + proc.stdin.on('error', () => {}); + proc.stdin.write(prompt, 'utf-8'); + proc.stdin.end(); + } catch (e) { + this._log(`Could not write the prompt to stdin: ${redactSecrets(e && e.message)}`); + } + }); + } + + // ------------------------------------------------------------------ + // Message handling + // ------------------------------------------------------------------ + + async _handleMessage(msg) { + let content = (msg.content || '').trim(); + const attachments = msg.attachments || []; + const attText = formatAttachmentsForPrompt(attachments, 'mcp'); + if (attText) content = content ? content + attText : attText.trim(); + if (!content) return; + + const channel = msg.sessionId || this.channelName; + this._stoppingChannels.delete(channel); + const sender = msg.senderName || msg.senderType || 'user'; + this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(truncate(content, 80))}`); + + // Resolve the working directory up front. Never silently fall back to the + // launcher/repo dir — a wrong cwd is a destructive surprise, not a default. + const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName); + if (this.workingDir && !this._dirExists(this.workingDir)) { + await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`); + return; + } + + const bin = this._findQwenBinary(); + if (!bin) { + await this.sendError(channel, `Qwen Code CLI not found. Install it with: ${INSTALL_HINT}`); + return; + } + + // HARD minimum gate. A confirmed-older CLI rejects this adapter's argv; an + // undetermined version (supported === null) proceeds leniently. + const ver = this._checkVersion(bin); + if (ver.supported === false) { + this._log(`Refusing to start: Qwen Code ${ver.version} < minimum ${QWEN_MIN_VERSION}`); + await this.sendError(channel, + `Qwen Code ${ver.version} is below the minimum supported version ${QWEN_MIN_VERSION} ` + + `(the first release that accepts --mcp-config). Upgrade with: ${INSTALL_HINT}`); + return; + } + if (ver.supported === null) { + this._log('Could not determine the Qwen Code version — proceeding leniently'); + } + + // Auto-title + resume-from on first encounter (parity with other adapters). + if (!this._titledSessions.has(channel)) { + this._titledSessions.add(channel); + try { + const info = await this.client.getSession(this.workspaceId, channel, this.token); + const resumeFrom = info.resumeFrom; + if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) { + this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] }; + this._saveSessions(); + } + const title = generateSessionTitle(content); + if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) { + await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true }); + } + } catch {} + } + + await this.sendStatus(channel, 'thinking...'); + + const creds = resolveQwenCredentials(this.agentEnv, this.workspaceModel); + + // The Claude builder is reused verbatim: Qwen Code takes the same + // --append-system-prompt hook, names MCP tools the same way + // (mcp____), and reaches the workspace through the same + // stdio MCP server, so the briefing is character-for-character the one + // that adapter already writes. + const systemPrompt = buildClaudeSystemPrompt({ + agentName: this.agentName, + workspaceId: this.workspaceId, + channelName: channel, + mode: this._mode, + model: creds.model || null, + browserEnabled: await this.getBrowserEnabled(), + toolMode: 'mcp', + }); + + const env = buildQwenChildEnv(getEnhancedEnv(this.agentEnv), { + creds, + defaultsPath: this._writeSystemDefaults() || '', + }); + + // One retry: a stale session id (its transcript pruned, or the directory + // re-keyed) fails the resume, and the turn is worth re-running fresh. + for (let attempt = 0; attempt < 2; attempt++) { + const resumeId = attempt === 0 ? this._resumableSession(channel, workingDir) : null; + + // Resuming → the CLI already holds the history, so send the bare turn. + // Fresh → prepend a recap of the channel. + let prompt = content; + if (!resumeId) { + const recap = await this._buildChannelRecap(channel, content); + if (recap) prompt = `${recap}\n\n---\n\n${content}`; + } + + const mcpConfigPath = this._writeMcpConfig(channel); + const args = buildQwenArgs({ + appendSystemPrompt: systemPrompt, + authType: creds.apiKey ? 'openai' : '', + model: creds.model, + resumeSessionId: resumeId || undefined, + mcpConfigPath: mcpConfigPath || undefined, + planMode: this._mode === 'plan', + }); + + let run; + try { + run = await this._runQwen(channel, bin, args, workingDir, prompt, env); + } finally { + if (mcpConfigPath) { try { fs.unlinkSync(mcpConfigPath); } catch {} } + } + + if (run.userStopped) { + // A stopped session is still resumable (verified), so the channel + // keeps its conversation. + this._rememberSession(channel, run.sessionId || resumeId, workingDir); + return; + } + + if (run.spawnError) { + await this.sendError(channel, + `Qwen Code CLI could not be started (${redactSecrets(run.spawnError)}). Reinstall it with: ${INSTALL_HINT}`); + return; + } + + const verdict = classifyQwenRun(run); + + // A resume the CLI could not find is the stale-session case: drop the + // binding and retry once from scratch. + if (resumeId && attempt === 0 && (verdict.kind === 'stale_session' || (!verdict.ok && !run.anyOutput))) { + this._log(`Resume of session ${resumeId} failed (${verdict.kind}) — clearing and retrying fresh`); + this._clearSession(channel); + continue; + } + + // Persist the session id for the next turn. It comes off the init frame, + // so this survives a turn that failed at the model call. + this._rememberSession(channel, run.sessionId || resumeId, workingDir); + + const resultText = run.result && run.result.text ? run.result.text.trim() : ''; + const finalText = resultText || (verdict.ok ? (run.lastAssistantText || '').trim() : ''); + + if (verdict.ok) { + try { + await this.sendResponse(channel, finalText || 'No response generated. Please try again.'); + } catch {} + return; + } + + // A failed run that still produced text (the model answered, then a later + // step failed) delivers the text with the reason appended rather than + // replacing an answer the user can use. + if (finalText) { + this._log(`Run reported ${verdict.kind} but returned text; text delivered`); + try { await this.sendResponse(channel, `${finalText}\n\n_${verdict.userMessage}_`); } catch {} + return; + } + + this._log(`Run failed (${verdict.kind}, exit ${run.code}): ${redactSecrets(truncate(run.stderr || '', 300))}`); + try { await this.sendError(channel, verdict.userMessage); } catch {} + return; + } + } +} + +module.exports = QwenCodeAdapter; diff --git a/packages/agent-connector/src/paths.js b/packages/agent-connector/src/paths.js index 91837abfc..72c851758 100644 --- a/packages/agent-connector/src/paths.js +++ b/packages/agent-connector/src/paths.js @@ -544,6 +544,9 @@ function _addAgentInstallerPaths(dirs) { if (process.env.GOOSE_BIN_DIR) _push(dirs, process.env.GOOSE_BIN_DIR); if (process.env.HERMES_INSTALL_DIR) _push(dirs, process.env.HERMES_INSTALL_DIR); if (process.env.HERMES_HOME) _push(dirs, path.join(process.env.HERMES_HOME, 'bin')); + // qwen — install-qwen-standalone.sh / .ps1 read these for a custom target. + if (process.env.QWEN_INSTALL_BIN_DIR) _push(dirs, process.env.QWEN_INSTALL_BIN_DIR); + if (process.env.QWEN_INSTALL_ROOT) _push(dirs, path.join(process.env.QWEN_INSTALL_ROOT, 'bin')); // uv — `uv tool install X` builds a venv at /X and only COPIES // the executable into uv's bin dir, which reaches PATH through a shell rc @@ -587,6 +590,9 @@ function _addAgentInstallerPaths(dirs) { _push(dirs, path.join(lad, 'Programs', 'cursor-agent')); // codebuddy's native Windows install. _push(dirs, path.join(lad, 'CodeBuddy', 'bin')); + // qwen — install-qwen-standalone.ps1 puts its qwen.cmd wrapper here and + // edits the user Path; on Unix the same installer uses ~/.local/bin. + _push(dirs, path.join(lad, 'qwen-code', 'bin')); // winget's shim dir — how GitHub Copilot CLI arrives on Windows for anyone // who didn't take the npm route. The copilot adapter knew it; nothing else did. _push(dirs, path.join(lad, 'Microsoft', 'WinGet', 'Links')); diff --git a/packages/agent-connector/test/agent-detection-matrix.test.js b/packages/agent-connector/test/agent-detection-matrix.test.js index be719c35a..65801614f 100644 --- a/packages/agent-connector/test/agent-detection-matrix.test.js +++ b/packages/agent-connector/test/agent-detection-matrix.test.js @@ -82,6 +82,9 @@ const LOC = { // through a shell alias a GUI process never sees. claudeLocal: '.claude/local', hermesHome: '.hermes/bin', + // Qwen Code's standalone installer (install-qwen-standalone.ps1) puts its + // qwen.cmd wrapper here; the .sh flavor uses ~/.local/bin. + qwenWin: IS_WINDOWS ? 'AppData/Local/qwen-code/bin' : null, agyWin: IS_WINDOWS ? 'AppData/Local/agy/bin' : null, // winget's shim dir — how the GitHub Copilot CLI arrives for a Windows user // who didn't take the npm route. @@ -204,6 +207,12 @@ const WHERE = { [pipx('coworker'), 'pipx install coworker', 'pip'], ], pi: [[LOC.nvm20, 'npm -g under a non-default node version', 'npm']], + 'qwen-code': [ + [LOC.nvm22, 'npm i -g @qwen-code/qwen-code under a node version manager', 'npm'], + [LOC.npmDefault, 'npm i -g @qwen-code/qwen-code', 'npm'], + [IS_WINDOWS ? null : LOC.localBin, 'install-qwen-standalone.sh', 'installer'], + [LOC.qwenWin, 'install-qwen-standalone.ps1', 'installer'], + ], } /** diff --git a/packages/agent-connector/test/qwen-code-stream.test.js b/packages/agent-connector/test/qwen-code-stream.test.js new file mode 100644 index 000000000..3b6d15605 --- /dev/null +++ b/packages/agent-connector/test/qwen-code-stream.test.js @@ -0,0 +1,280 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + QWEN_DEFAULT_BASE_URL, + QWEN_MIN_VERSION, + EXCLUDED_TOOLS, + buildQwenArgs, + buildQwenChildEnv, + buildQwenSystemDefaults, + classifyQwenRun, + classifyQwenVersion, + interpretQwenFrame, + parseFrame, + qwenSystemDefaultsSource, + redactArgs, + redactSecrets, + resolveQwenCredentials, +} = require('../src/adapters/qwen-code-stream'); + +// Frames below are copied from real `qwen -o stream-json` runs of +// @qwen-code/qwen-code 0.25.1 (uuids and long fields trimmed). + +describe('qwen-code version gate', () => { + it('accepts the floor and anything newer', () => { + assert.equal(classifyQwenVersion(QWEN_MIN_VERSION).supported, true); + assert.equal(classifyQwenVersion('0.25.1\n').supported, true); + assert.equal(classifyQwenVersion('1.0.0').supported, true); + }); + + it('refuses a release that rejects --mcp-config', () => { + assert.deepEqual(classifyQwenVersion('0.15.0'), { version: '0.15.0', supported: false, tested: true }); + }); + + it('reads a prerelease by its base version', () => { + assert.equal(classifyQwenVersion('0.25.2-preview.0').version, '0.25.2'); + }); + + it('proceeds when the version cannot be read', () => { + assert.equal(classifyQwenVersion('').supported, null); + assert.equal(classifyQwenVersion('command not found').supported, null); + }); +}); + +describe('qwen-code credentials', () => { + it("takes key, base URL and model from the agent's own form fields", () => { + const c = resolveQwenCredentials({ + LLM_API_KEY: 'sk-agent', LLM_BASE_URL: 'https://coding.dashscope.aliyuncs.com/v1', LLM_MODEL: 'qwen3.6-plus', + OPENAI_API_KEY: 'sk-shell', OPENAI_BASE_URL: 'https://api.openai.com/v1', OPENAI_MODEL: 'gpt-x', + }); + assert.deepEqual(c, { + apiKey: 'sk-agent', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', model: 'qwen3.6-plus', source: 'agent', + }); + }); + + it('never mixes a form key with a base URL exported for another tool', () => { + // The form left the base URL blank; the shell has OPENAI_BASE_URL for + // something else. The agent's key must not be sent there. + const c = resolveQwenCredentials({ LLM_API_KEY: 'sk-agent', OPENAI_BASE_URL: 'https://api.openai.com/v1' }); + assert.equal(c.baseUrl, ''); + const env = buildQwenChildEnv({ OPENAI_BASE_URL: 'https://api.openai.com/v1', OPENAI_MODEL: 'gpt-x' }, { creds: c }); + assert.equal(env.OPENAI_BASE_URL, QWEN_DEFAULT_BASE_URL); + assert.equal(env.OPENAI_MODEL, undefined); + }); + + it('falls back to the OPENAI_* set, including QWEN_MODEL', () => { + const c = resolveQwenCredentials({ OPENAI_API_KEY: 'sk-env', QWEN_MODEL: 'qwen3.8-flash' }); + assert.deepEqual(c, { apiKey: 'sk-env', baseUrl: '', model: 'qwen3.8-flash', source: 'env' }); + }); + + it('never sends a key exported for another tool to Model Studio by default', () => { + // An OPENAI_API_KEY in the shell is most likely an OpenAI key. Pointing a + // blank base URL at DashScope would hand it to a third party. + const c = resolveQwenCredentials({ OPENAI_API_KEY: 'sk-openai-key-123456' }); + const env = buildQwenChildEnv({ OPENAI_API_KEY: 'sk-openai-key-123456' }, { creds: c }); + assert.equal(env.OPENAI_BASE_URL, undefined); + }); + + it("puts the workspace's model picker ahead of the configured model", () => { + const c = resolveQwenCredentials({ LLM_API_KEY: 'k', LLM_MODEL: 'configured' }, 'picked'); + assert.equal(c.model, 'picked'); + }); + + it("leaves a keyless agent to the CLI's own settings", () => { + const c = resolveQwenCredentials({}); + assert.equal(c.source, 'none'); + const env = buildQwenChildEnv({ OPENAI_BASE_URL: 'keep-me' }, { creds: c }); + assert.equal(env.OPENAI_BASE_URL, 'keep-me'); + assert.equal(env.OPENAI_API_KEY, undefined); + }); + + it('writes the settings layer and silences the per-run yolo warning', () => { + const env = buildQwenChildEnv({}, { creds: { apiKey: 'k', baseUrl: 'u', model: 'm' }, defaultsPath: '/x/defaults.json' }); + assert.equal(env.OPENAI_API_KEY, 'k'); + assert.equal(env.OPENAI_BASE_URL, 'u'); + assert.equal(env.OPENAI_MODEL, 'm'); + assert.equal(env.QWEN_CODE_SYSTEM_DEFAULTS_PATH, '/x/defaults.json'); + assert.equal(env.QWEN_CODE_SUPPRESS_YOLO_WARNING, '1'); + }); +}); + +describe('qwen-code settings layer', () => { + it('turns off auto-memory and self-update', () => { + const d = buildQwenSystemDefaults(null); + assert.equal(d.memory.enableManagedAutoMemory, false); + assert.equal(d.memory.enableManagedAutoDream, false); + assert.equal(d.general.enableAutoUpdate, false); + }); + + it("lets a machine-wide system-defaults file win key by key", () => { + const d = buildQwenSystemDefaults({ memory: { enableManagedAutoMemory: true }, ui: { theme: 'x' } }); + assert.equal(d.memory.enableManagedAutoMemory, true); + assert.equal(d.memory.enableManagedAutoDream, false); + assert.equal(d.ui.theme, 'x'); + }); + + it("finds the CLI's own system-defaults file", () => { + assert.equal(qwenSystemDefaultsSource({}, 'linux'), '/etc/qwen-code/system-defaults.json'); + assert.equal(qwenSystemDefaultsSource({}, 'darwin'), '/Library/Application Support/QwenCode/system-defaults.json'); + assert.equal(qwenSystemDefaultsSource({}, 'win32'), 'C:\\ProgramData\\qwen-code\\system-defaults.json'); + assert.equal(qwenSystemDefaultsSource({ QWEN_CODE_SYSTEM_SETTINGS_PATH: '/opt/q/settings.json' }, 'linux'), + '/opt/q/system-defaults.json'); + assert.equal(qwenSystemDefaultsSource({ QWEN_CODE_SYSTEM_DEFAULTS_PATH: '/d.json' }, 'linux'), '/d.json'); + }); +}); + +describe('qwen-code argv', () => { + it('runs headless with full tool access and no prompt in argv', () => { + const args = buildQwenArgs({ appendSystemPrompt: 'brief', authType: 'openai', model: 'qwen3.8-flash', mcpConfigPath: '/m.json' }); + assert.deepEqual(args.slice(0, 4), ['--output-format', 'stream-json', '--approval-mode', 'yolo']); + assert.equal(args[args.indexOf('--auth-type') + 1], 'openai'); + assert.equal(args[args.indexOf('--model') + 1], 'qwen3.8-flash'); + assert.equal(args[args.indexOf('--mcp-config') + 1], '/m.json'); + assert.equal(args[args.indexOf('--append-system-prompt') + 1], 'brief'); + assert.ok(!args.includes('--resume')); + }); + + it('keeps the excluded-tools list closed by another flag', () => { + // --exclude-tools is a yargs array: it would swallow a following + // positional, so a flag must come right after its values. + const args = buildQwenArgs({}); + const i = args.indexOf('--exclude-tools'); + assert.deepEqual(args.slice(i + 1, i + 1 + EXCLUDED_TOOLS.length), EXCLUDED_TOOLS); + const next = args[i + 1 + EXCLUDED_TOOLS.length]; + assert.ok(next === undefined || next.startsWith('--')); + }); + + it('resumes, and runs read-only in plan mode', () => { + const args = buildQwenArgs({ resumeSessionId: 'sid-1', planMode: true }); + assert.equal(args[args.indexOf('--resume') + 1], 'sid-1'); + assert.equal(args[args.indexOf('--approval-mode') + 1], 'plan'); + }); + + it('omits the auth type and model when the CLI is to decide', () => { + const args = buildQwenArgs({ authType: '', model: '' }); + assert.ok(!args.includes('--auth-type')); + assert.ok(!args.includes('--model')); + }); + + it('redacts the session id and system prompt for logging', () => { + const shown = redactArgs(buildQwenArgs({ resumeSessionId: 'sid-1', appendSystemPrompt: 'secret brief' })); + assert.ok(!shown.includes('sid-1')); + assert.ok(!shown.includes('secret brief')); + }); + + it('redacts keys from free text', () => { + assert.ok(!redactSecrets('key sk-ws-H.PHMLRHM.gozS.abcdef12345').includes('PHMLRHM')); + }); +}); + +describe('qwen-code frames', () => { + it('reads the session id off the init frame', () => { + const ev = interpretQwenFrame(parseFrame(JSON.stringify({ + type: 'system', subtype: 'init', session_id: 'a5d2610a', model: 'qwen3.8-flash', permission_mode: 'yolo', + mcp_servers: [{ name: 'openagents-workspace', status: 'connected' }], qwen_code_version: '0.25.1', + }))); + assert.equal(ev.kind, 'init'); + assert.equal(ev.sessionId, 'a5d2610a'); + assert.deepEqual(ev.mcpServers, [{ name: 'openagents-workspace', status: 'connected' }]); + }); + + it('ignores the goal_state ping, tool results, and thinking', () => { + assert.equal(interpretQwenFrame({ type: 'stream_event', event: { type: 'goal_state' } }).kind, 'ignored'); + assert.equal(interpretQwenFrame({ type: 'user', message: { content: [{ type: 'tool_result' }] } }).kind, 'ignored'); + assert.equal(interpretQwenFrame({ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hm' }] } }).kind, 'ignored'); + }); + + it('reports text and a shell call for the ticker', () => { + const text = interpretQwenFrame({ type: 'assistant', message: { content: [{ type: 'text', text: 'I will run it.\n\n' }] } }); + assert.deepEqual(text.texts, ['I will run it.']); + const tool = interpretQwenFrame({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'run_shell_command', input: { command: 'cat hello.txt', description: 'Print it' } }, + ] } }); + assert.deepEqual(tool.tools, [{ name: 'run_shell_command', preview: 'cat hello.txt' }]); + }); + + it('announces an MCP call as the tool it ran, and skips the lookup', () => { + const lookup = interpretQwenFrame({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'tool_search', input: { query: 'select:mcp__magic__get_magic_number' } }, + ] } }); + assert.equal(lookup.kind, 'ignored'); + const call = interpretQwenFrame({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'tool_call', input: { name: 'mcp__openagents-workspace__workspace_get_history', arguments: { channel: 'c1' } } }, + ] } }); + assert.deepEqual(call.tools, [{ name: 'workspace_get_history', preview: '{"channel":"c1"}' }]); + }); + + it('keeps a streamed API error out of the answer text', () => { + const ev = interpretQwenFrame({ type: 'assistant', message: { content: [ + { type: 'text', text: '[API Error: 401 Incorrect API key provided. For details, see: https://help.aliyun.com/zh/model-studio/error-code#apikey-error]' }, + ] } }); + assert.deepEqual(ev.texts, []); + assert.equal(ev.apiErrors.length, 1); + }); + + it('reads success and failure result frames', () => { + const ok = interpretQwenFrame({ type: 'result', subtype: 'success', is_error: false, result: 'PONG', session_id: 's', num_turns: 1 }); + assert.equal(ok.text, 'PONG'); + assert.equal(ok.isError, false); + const bad = interpretQwenFrame({ type: 'result', subtype: 'error_during_execution', is_error: true, + error: { message: '[API Error: 404 The model `x` does not exist or you do not have access to it.]' } }); + assert.equal(bad.isError, true); + assert.equal(bad.text, ''); + assert.match(bad.errorMessage, /404/); + }); + + it('flags a frame type it has never seen', () => { + assert.equal(interpretQwenFrame({ type: 'brand_new' }).kind, 'unknown'); + assert.equal(parseFrame('not json'), null); + }); +}); + +describe('qwen-code run classification', () => { + const failed = (message) => ({ result: { isError: true, errorMessage: message }, code: 1 }); + + it('trusts a clean result frame', () => { + assert.equal(classifyQwenRun({ result: { isError: false, text: 'x' }, code: 0 }).ok, true); + }); + + it('explains a missing key without echoing the CLI', () => { + const v = classifyQwenRun(failed('No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.')); + assert.equal(v.kind, 'not_configured'); + assert.match(v.userMessage, /API key/); + assert.match(v.userMessage, /dashscope/); + }); + + it('classifies the provider errors the CLI passes through', () => { + assert.equal(classifyQwenRun(failed('[API Error: 401 Incorrect API key provided.]')).kind, 'auth'); + assert.equal(classifyQwenRun(failed('[API Error: 401 invalid access token or token expired]')).kind, 'auth'); + assert.equal(classifyQwenRun(failed('[API Error: 404 The model `no-such` does not exist or you do not have access to it.]')).kind, 'model'); + assert.equal(classifyQwenRun(failed('[API Error: Connection error. (cause: fetch failed)]')).kind, 'network'); + assert.equal(classifyQwenRun(failed('[API Error: 429 Requests rate limit exceeded]')).kind, 'quota'); + assert.equal(classifyQwenRun(failed('[API Error: 503 Service Unavailable]')).kind, 'model_service'); + }); + + it("keeps the provider's own words in the message", () => { + const v = classifyQwenRun(failed('[API Error: 401 Incorrect API key provided.]')); + assert.match(v.userMessage, /Incorrect API key provided/); + assert.match(v.userMessage, /Coding Plan/); + }); + + it('reads a stop, a turn cap and a budget off the exit code', () => { + assert.equal(classifyQwenRun({ code: 130, stderr: 'Operation cancelled.' }).kind, 'interrupted'); + assert.equal(classifyQwenRun({ code: null, signal: 'SIGKILL' }).kind, 'interrupted'); + assert.equal(classifyQwenRun({ code: 53 }).kind, 'turn_limit'); + assert.equal(classifyQwenRun({ code: 55 }).kind, 'budget'); + }); + + it('recognizes a resume the CLI could not find', () => { + const v = classifyQwenRun({ code: 1, stderr: 'No saved session found with ID a5d2610a. Run `qwen --resume` without an ID to choose from existing sessions.' }); + assert.equal(v.kind, 'stale_session'); + }); + + it('falls back to stderr, then to the exit code', () => { + assert.match(classifyQwenRun({ code: 1, stderr: 'Invalid --session-id: "x".\nUsage: qwen' }).userMessage, /Invalid --session-id/); + assert.match(classifyQwenRun({ code: 7 }).userMessage, /code 7/); + }); +}); diff --git a/packages/agent-connector/test/qwen-code.test.js b/packages/agent-connector/test/qwen-code.test.js new file mode 100644 index 000000000..1eb3e8861 --- /dev/null +++ b/packages/agent-connector/test/qwen-code.test.js @@ -0,0 +1,519 @@ +'use strict'; + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const QwenCodeAdapter = require('../src/adapters/qwen-code'); +const { ADAPTER_MAP, createAdapter } = require('../src/adapters'); + +// --------------------------------------------------------------------------- +// A mock Qwen Code CLI: a Node script that records how it was invoked and +// replays what the real CLI (0.25.1) printed for each scenario, so the +// adapter's spawn → pipe-prompt → parse → reply path runs with no real CLI, +// account, or network. +// +// The failing scenarios matter most: a model error is streamed as assistant +// text AND reported in an is_error result frame with exit 1; a missing key +// fails before the init frame; a resume of an unknown session writes only to +// stderr; a stop exits 130 with no result frame. +// --------------------------------------------------------------------------- +let tmpRoot; +let fakeBin; +let capturePath; + +const FAKE_SCRIPT = `#!/usr/bin/env node +'use strict'; +const fs = require('fs'); +const args = process.argv.slice(2); +if (args[0] === '--version') { + process.stdout.write((process.env.FAKE_VERSION || '0.25.1') + '\\n'); + process.exit(0); +} +const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); +const resumeAt = args.indexOf('--resume'); +const sid = resumeAt >= 0 ? args[resumeAt + 1] : (process.env.FAKE_SESSION_ID || 'sess-abc'); +const init = () => { + w({ type: 'system', subtype: 'init', session_id: sid, model: 'qwen3.8-flash', permission_mode: 'yolo', + mcp_servers: [{ name: 'openagents-workspace', status: 'connected' }], tools: [], qwen_code_version: '0.25.1' }); + w({ type: 'stream_event', session_id: sid, event: { type: 'goal_state', goal_state: { v: 2, goal: null, activity: 'idle' } } }); +}; +const say = (block) => w({ type: 'assistant', session_id: sid, message: { role: 'assistant', content: [block] } }); +const result = (o) => w({ type: 'result', session_id: sid, num_turns: 1, duration_ms: 12, ...o }); + +let stdin = ''; +process.stdin.on('data', (c) => { stdin += c; }); +process.stdin.on('end', () => { + if (process.env.FAKE_CAPTURE) { + const pick = (k) => process.env[k] === undefined ? null : process.env[k]; + fs.writeFileSync(process.env.FAKE_CAPTURE, JSON.stringify({ args, stdin, cwd: process.cwd(), env: { + OPENAI_API_KEY: pick('OPENAI_API_KEY'), + OPENAI_BASE_URL: pick('OPENAI_BASE_URL'), + OPENAI_MODEL: pick('OPENAI_MODEL'), + QWEN_CODE_SYSTEM_DEFAULTS_PATH: pick('QWEN_CODE_SYSTEM_DEFAULTS_PATH'), + QWEN_CODE_SUPPRESS_YOLO_WARNING: pick('QWEN_CODE_SUPPRESS_YOLO_WARNING'), + } })); + } + const scenario = process.env.FAKE_SCENARIO || 'success'; + + if (scenario === 'success') { + init(); + say({ type: 'thinking', thinking: 'The user wants me to read a file.', signature: '' }); + say({ type: 'text', text: 'Reading the file.\\n\\n' }); + say({ type: 'tool_use', id: 'call_1', name: 'run_shell_command', input: { command: 'cat src/app.js', description: 'Show it' } }); + w({ type: 'user', session_id: sid, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', is_error: false, content: 'x' }] } }); + say({ type: 'text', text: 'Done - read src/app.js.' }); + result({ subtype: 'success', is_error: false, result: 'Done - read src/app.js.' }); + process.exit(0); + } + if (scenario === 'mcp') { + init(); + say({ type: 'tool_use', id: 'c1', name: 'tool_search', input: { query: 'select:mcp__openagents-workspace__workspace_get_history' } }); + say({ type: 'tool_use', id: 'c2', name: 'tool_call', input: { name: 'mcp__openagents-workspace__workspace_get_history', arguments: { channel: 'thread' } } }); + say({ type: 'text', text: 'Nothing new in the channel.' }); + result({ subtype: 'success', is_error: false, result: 'Nothing new in the channel.' }); + process.exit(0); + } + if (scenario === 'not_configured') { + // Verified: no init frame, an is_error result, exit 1. + result({ subtype: 'error_during_execution', is_error: true, num_turns: 0, + error: { message: 'No auth type is selected. Please configure an auth type (e.g. via settings or \`--auth-type\`) before running in non-interactive mode.' } }); + process.exit(1); + } + if (scenario === 'auth') { + init(); + const msg = '[API Error: 401 Incorrect API key provided. For details, see: https://help.aliyun.com/zh/model-studio/error-code#apikey-error]'; + say({ type: 'text', text: msg }); + result({ subtype: 'error_during_execution', is_error: true, error: { message: msg } }); + process.exit(1); + } + if (scenario === 'stale_resume') { + if (resumeAt >= 0) { + process.stderr.write('No saved session found with ID ' + sid + '. Run \`qwen --resume\` without an ID to choose from existing sessions.\\n'); + process.exit(1); + } + init(); + result({ subtype: 'success', is_error: false, result: 'Fresh answer.' }); + process.exit(0); + } + if (scenario === 'empty_result') { + init(); + say({ type: 'text', text: 'The answer is 42.' }); + result({ subtype: 'success', is_error: false, result: '' }); + process.exit(0); + } + if (scenario === 'unknown_frames') { + init(); + w({ type: 'brand_new_frame', payload: { a: 1 } }); + result({ subtype: 'success', is_error: false, result: 'Survived.' }); + process.exit(0); + } + if (scenario === 'hang') { + // A long tool run. The real CLI answers SIGINT/SIGTERM by cancelling the + // turn and exiting 130 with "Operation cancelled." on stderr. + init(); + say({ type: 'tool_use', id: 'c1', name: 'run_shell_command', input: { command: 'sleep 90' } }); + const bye = () => { process.stderr.write('Operation cancelled.\\n'); process.exit(130); }; + process.on('SIGINT', bye); + process.on('SIGTERM', bye); + setInterval(() => {}, 1000); + return; + } + process.exit(0); +}); +`; + +const SESSIONS_FILE = path.join( + os.homedir(), '.openagents', 'sessions', 'ws-qwen-test_qwen-bot_qwen-code.json', +); + +before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-qwen-')); + fakeBin = path.join(tmpRoot, 'fake-qwen.js'); + fs.writeFileSync(fakeBin, FAKE_SCRIPT, { mode: 0o755 }); + capturePath = path.join(tmpRoot, 'capture.json'); +}); + +after(() => { + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {} + try { fs.rmSync(SESSIONS_FILE, { force: true }); } catch {} +}); + +beforeEach(() => { + try { fs.rmSync(capturePath, { force: true }); } catch {} + try { fs.rmSync(SESSIONS_FILE, { force: true }); } catch {} +}); + +function makeAdapter(extra = {}) { + const a = new QwenCodeAdapter({ + workspaceId: 'ws-qwen-test', + channelName: 'thread', + token: 'token', + agentName: 'qwen-bot', + agentType: 'qwen-code', + endpoint: 'https://example.invalid', + agentEnv: { + PATH: process.env.PATH, + ...(extra.agentEnv || {}), + FAKE_CAPTURE: capturePath, + FAKE_SCENARIO: extra.scenario || 'success', + ...(extra.fakeVersion ? { FAKE_VERSION: extra.fakeVersion } : {}), + }, + workingDir: extra.workingDir || tmpRoot, + }); + a._captured = { thinking: [], status: [], response: [], error: [], logs: [] }; + a.sendThinking = async (_c, t) => { a._captured.thinking.push(t); }; + a.sendStatus = async (_c, t) => { a._captured.status.push(t); }; + a.sendResponse = async (_c, t) => { a._captured.response.push(t); }; + a.sendError = async (_c, t) => { a._captured.error.push(t); }; + a._log = (m) => { a._captured.logs.push(String(m)); }; + a.getBrowserEnabled = async () => false; + a.client = { + getSession: async () => ({ title: 'Session 1', titleManuallySet: false, resumeFrom: null }), + updateSession: async () => ({}), + getRecentMessages: async () => [], + sendMessage: async () => ({}), + getTodos: async () => ({ todos: [] }), + }; + a._findQwenBinary = () => fakeBin; + // Run the mock under THIS interpreter. Without it the adapter would look for + // a Node >= 22 of its own, which a CI runner on an older Node lacks. + a._findNodeBin = () => process.execPath; + // Keep the real settings layer out of the developer's ~/.openagents. + a._writeSystemDefaults = () => path.join(tmpRoot, 'system-defaults.json'); + if (extra.mode) a._mode = extra.mode; + return a; +} + +const readCapture = (a) => { + if (!fs.existsSync(capturePath)) { + const d = a && a._captured; + throw new Error( + 'the mock CLI never wrote its capture file — it did not run to completion.\n' + + ` errors: ${JSON.stringify(d && d.error)}\n` + + ` responses: ${JSON.stringify(d && d.response)}\n` + + ` adapter log:\n ${((d && d.logs) || []).join('\n ')}`, + ); + } + return JSON.parse(fs.readFileSync(capturePath, 'utf-8')); +}; + +const send = (a, content = 'do the thing') => + a._handleMessage({ content, sessionId: 'thread', senderType: 'human', senderName: 'user' }); + +const argAfter = (cap, flag) => cap.args[cap.args.indexOf(flag) + 1]; + +describe('QwenCodeAdapter — registration', () => { + it('is reachable through the adapter registry', () => { + assert.ok(ADAPTER_MAP['qwen-code']); + const a = createAdapter('qwen-code', { + workspaceId: 'w', channelName: 'c', token: 't', agentName: 'n', endpoint: 'https://e', agentEnv: {}, + }); + assert.equal(a.constructor.name, 'QwenCodeAdapter'); + }); + + it('is in the bundled registry with the env names the adapter reads', () => { + const entry = require('../registry.json').find((e) => e.name === 'qwen-code'); + assert.ok(entry, 'registry.json must carry qwen-code'); + assert.equal(entry.install.binary, 'qwen'); + const rules = Object.fromEntries(entry.resolve_env.rules.map((r) => [r.from, r.to])); + assert.deepEqual(rules, { LLM_API_KEY: 'OPENAI_API_KEY', LLM_BASE_URL: 'OPENAI_BASE_URL', LLM_MODEL: 'OPENAI_MODEL' }); + }); +}); + +describe('QwenCodeAdapter — headless invocation', () => { + it('pipes the prompt over stdin and keeps it out of argv', async () => { + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'sk-test-key-123456' } }); + await send(a, 'refactor the auth module'); + const cap = readCapture(a); + assert.ok(cap.stdin.includes('refactor the auth module')); + assert.equal(argAfter(cap, '--output-format'), 'stream-json'); + assert.ok(!cap.args.some((x) => x.includes('refactor the auth module'))); + assert.equal(cap.cwd, fs.realpathSync(tmpRoot)); + }); + + it("hands the agent's key over in the environment, never in argv", async () => { + const a = makeAdapter({ agentEnv: { + LLM_API_KEY: 'sk-test-key-123456', LLM_BASE_URL: 'https://coding.dashscope.aliyuncs.com/v1', LLM_MODEL: 'qwen3.6-plus', + } }); + await send(a); + const cap = readCapture(a); + assert.equal(cap.env.OPENAI_API_KEY, 'sk-test-key-123456'); + assert.equal(cap.env.OPENAI_BASE_URL, 'https://coding.dashscope.aliyuncs.com/v1'); + assert.equal(argAfter(cap, '--auth-type'), 'openai'); + assert.equal(argAfter(cap, '--model'), 'qwen3.6-plus'); + assert.ok(!cap.args.some((x) => x.includes('sk-test-key-123456'))); + }); + + it('points a blank base URL at Model Studio', async () => { + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'sk-test-key-123456', OPENAI_BASE_URL: 'https://api.openai.com/v1' } }); + await send(a); + assert.equal(readCapture(a).env.OPENAI_BASE_URL, 'https://dashscope.aliyuncs.com/compatible-mode/v1'); + }); + + it("leaves a keyless agent to the CLI's own settings", async () => { + const a = makeAdapter(); + await send(a); + const cap = readCapture(a); + assert.ok(!cap.args.includes('--auth-type')); + assert.equal(cap.env.OPENAI_API_KEY, null); + }); + + it('loads the settings layer that turns auto-memory off', async () => { + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'k-123456789' } }); + await send(a); + const cap = readCapture(a); + assert.equal(cap.env.QWEN_CODE_SYSTEM_DEFAULTS_PATH, path.join(tmpRoot, 'system-defaults.json')); + assert.equal(cap.env.QWEN_CODE_SUPPRESS_YOLO_WARNING, '1'); + }); + + it('sends the workspace briefing and the MCP config that carries the tools', async () => { + const a = makeAdapter(); + await send(a); + const cap = readCapture(a); + assert.ok(argAfter(cap, '--append-system-prompt').includes('qwen-bot'), 'the briefing must name the agent'); + const mcpPath = argAfter(cap, '--mcp-config'); + assert.ok(mcpPath.includes(path.join('.openagents', 'mcp-configs'))); + assert.equal(fs.existsSync(mcpPath), false, 'the token-bearing config must not outlive the run'); + }); + + it('runs read-only in plan mode', async () => { + const a = makeAdapter({ mode: 'plan' }); + await send(a); + assert.equal(argAfter(readCapture(a), '--approval-mode'), 'plan'); + }); + + it('passes the workspace-selected model ahead of the configured one', async () => { + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'k-123456789', LLM_MODEL: 'configured-model' } }); + a.workspaceModel = 'picked-model'; + await send(a); + const cap = readCapture(a); + assert.equal(argAfter(cap, '--model'), 'picked-model'); + assert.equal(cap.env.OPENAI_MODEL, 'picked-model'); + assert.equal(a.modelLabel(), 'picked-model'); + }); +}); + +describe('QwenCodeAdapter — replies', () => { + it('answers from the result frame and streams progress along the way', async () => { + const a = makeAdapter(); + await send(a); + assert.deepEqual(a._captured.response, ['Done - read src/app.js.']); + assert.deepEqual(a._captured.thinking, ['Reading the file.', 'Done - read src/app.js.']); + assert.ok(a._captured.status.some((s) => s === 'run_shell_command › cat src/app.js')); + assert.deepEqual(a._captured.error, []); + }); + + it('announces a workspace MCP call by the tool it ran', async () => { + const a = makeAdapter({ scenario: 'mcp' }); + await send(a); + assert.ok(a._captured.status.some((s) => s.startsWith('workspace_get_history'))); + assert.ok(!a._captured.status.some((s) => s.startsWith('tool_search'))); + }); + + it('tells a keyless agent to set a key', async () => { + const a = makeAdapter({ scenario: 'not_configured' }); + await send(a); + assert.deepEqual(a._captured.response, []); + assert.equal(a._captured.error.length, 1); + assert.match(a._captured.error[0], /no model credentials/); + }); + + it('reports a rejected key as an error, not as the answer', async () => { + // The CLI streams "[API Error: 401 …]" as assistant text before the + // result frame; posting it as a reply would read as the agent's answer. + const a = makeAdapter({ scenario: 'auth' }); + await send(a); + assert.deepEqual(a._captured.response, []); + assert.deepEqual(a._captured.thinking, []); + assert.equal(a._captured.error.length, 1); + assert.match(a._captured.error[0], /rejected the API key/); + assert.match(a._captured.error[0], /Incorrect API key provided/); + }); + + it('falls back to the streamed text when a clean run summarizes nothing', async () => { + const a = makeAdapter({ scenario: 'empty_result' }); + await send(a); + assert.deepEqual(a._captured.response, ['The answer is 42.']); + }); + + it('keeps answering when the CLI emits a frame type it has never seen', async () => { + const a = makeAdapter({ scenario: 'unknown_frames' }); + await send(a); + assert.deepEqual(a._captured.response, ['Survived.']); + assert.ok(a._captured.logs.some((l) => l.includes('brand_new_frame'))); + }); +}); + +describe('QwenCodeAdapter — session continuity', () => { + it('resumes the channel session on the next turn', async () => { + const a = makeAdapter(); + await send(a); + await send(a, 'and now the tests'); + const cap = readCapture(a); + assert.equal(argAfter(cap, '--resume'), 'sess-abc'); + // Resuming means the CLI already holds the history; only the bare turn is + // sent, with no recap prepended. + assert.equal(cap.stdin.trim(), 'and now the tests'); + }); + + it('remembers the session id from the init frame even when the turn failed', async () => { + const a = makeAdapter({ scenario: 'auth' }); + await send(a); + assert.equal(a._channelSessions.thread.sessionId, 'sess-abc'); + }); + + it('drops a stale session and retries once from scratch', async () => { + const a = makeAdapter({ scenario: 'stale_resume' }); + a._channelSessions.thread = { sessionId: 'gone-session', workingDir: tmpRoot }; + await send(a); + assert.deepEqual(a._captured.response, ['Fresh answer.']); + assert.deepEqual(a._captured.error, []); + assert.equal(a._channelSessions.thread.sessionId, 'sess-abc'); + }); + + it('never resumes a session recorded against a different working directory', () => { + // Qwen Code files a transcript under the directory it ran in, so a resume + // from elsewhere fails — start fresh instead. + const a = makeAdapter(); + a._channelSessions.thread = { sessionId: 's-1', workingDir: '/somewhere/else' }; + assert.equal(a._resumableSession('thread', tmpRoot), null); + assert.equal(a._resumableSession('thread', '/somewhere/else'), 's-1'); + }); +}); + +describe('QwenCodeAdapter — stop', () => { + it('stops a running turn and keeps its session resumable', async () => { + const a = makeAdapter({ scenario: 'hang' }); + const turn = send(a); + for (let i = 0; i < 200 && !(a._channelProcesses.thread && a._captured.status.length > 1); i++) { + await new Promise((r) => setTimeout(r, 25)); + } + assert.ok(a._channelProcesses.thread, 'the run never started'); + const started = Date.now(); + await a._onControlAction('stop', { channel: 'thread' }); + await turn; + assert.ok(Date.now() - started < 3000, 'SIGINT should end the run without escalating'); + assert.equal(a._channelProcesses.thread, undefined); + assert.deepEqual(a._captured.response, []); + assert.deepEqual(a._captured.error, [], 'a user stop is not an error'); + assert.equal(a._channelSessions.thread.sessionId, 'sess-abc'); + }); + + it('kills the whole process tree on Windows', async () => { + // The Windows entry script relaunches the CLI as a child; terminating only + // the parent would leave that child running the turn. + const { EventEmitter } = require('node:events'); + const a = makeAdapter(); + a._isWindows = true; + const proc = new EventEmitter(); + proc.pid = 4242; + proc.exitCode = null; + proc.kill = () => { throw new Error('kill() alone would orphan the CLI'); }; + const killed = []; + a._killTree = (pid) => { killed.push(pid); proc.exitCode = 1; proc.emit('exit', 1, null); }; + assert.equal(await a._stopProcess(proc), true); + assert.deepEqual(killed, [4242]); + }); +}); + +describe('QwenCodeAdapter — binary resolution', () => { + function makeStandalone(root, { windows = false } = {}) { + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(root, 'manifest.json'), JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.25.1' })); + fs.writeFileSync(path.join(root, 'lib', 'cli-entry.js'), ''); + const node = windows ? path.join(root, 'node', 'node.exe') : path.join(root, 'node', 'bin', 'node'); + fs.mkdirSync(path.dirname(node), { recursive: true }); + fs.writeFileSync(node, ''); + return node; + } + + it("runs a standalone install on its own bundled Node, through the installer's wrapper", () => { + const root = path.join(tmpRoot, 'local', 'lib', 'qwen-code'); + const node = makeStandalone(root); + const wrapper = path.join(tmpRoot, 'local', 'bin', 'qwen'); + fs.mkdirSync(path.dirname(wrapper), { recursive: true }); + fs.writeFileSync(wrapper, `#!/usr/bin/env sh\nexec '${path.join(root, 'bin', 'qwen')}' "$@"\n`); + const plan = makeAdapter()._spawnPlan(wrapper, ['--version']); + assert.equal(plan.command, node); + assert.deepEqual(plan.args, [path.join(root, 'lib', 'cli-entry.js'), '--version']); + assert.equal(plan.env.QWEN_CODE_LAUNCHER_PATH, path.join(root, 'bin', 'qwen')); + }); + + it('recognizes the Windows standalone layout from its outer qwen.cmd', () => { + const base = path.join(tmpRoot, 'LocalAppData', 'qwen-code'); + const root = path.join(base, 'qwen-code'); + const a = makeAdapter(); + a._isWindows = true; + const node = makeStandalone(root, { windows: true }); + const outer = path.join(base, 'bin', 'qwen.cmd'); + fs.mkdirSync(path.dirname(outer), { recursive: true }); + fs.writeFileSync(outer, `@echo off\r\ncall "${path.join(root, 'bin', 'qwen.cmd')}" %*\r\n`); + const plan = a._spawnPlan(outer, ['--version']); + assert.equal(plan.command, node); + assert.equal(plan.args[0], path.join(root, 'lib', 'cli-entry.js')); + }); + + it("runs an npm install's entry script under Node, not through its shim", () => { + const plan = makeAdapter()._spawnPlan(fakeBin, ['--version']); + assert.equal(plan.command, process.execPath); + assert.deepEqual(plan.args, [fakeBin, '--version']); + }); +}); + +describe('QwenCodeAdapter — settings layer file', () => { + it('merges a machine-wide system-defaults file instead of replacing it', () => { + const a = makeAdapter(); + delete a._writeSystemDefaults; + const home = path.join(tmpRoot, 'home-merge'); + const admin = path.join(tmpRoot, 'admin-defaults.json'); + fs.writeFileSync(admin, JSON.stringify({ memory: { enableManagedAutoMemory: true } })); + a.agentEnv = { ...a.agentEnv, QWEN_CODE_SYSTEM_DEFAULTS_PATH: admin }; + const realHome = os.homedir; + os.homedir = () => home; + try { + const target = a._writeSystemDefaults(); + assert.equal(target, path.join(home, '.openagents', 'qwen-code', 'system-defaults.json')); + const written = JSON.parse(fs.readFileSync(target, 'utf-8')); + assert.equal(written.memory.enableManagedAutoMemory, true); + assert.equal(written.memory.enableManagedAutoDream, false); + } finally { + os.homedir = realHome; + } + }); + + it("leaves an administrator's unreadable defaults file in charge", () => { + const a = makeAdapter(); + delete a._writeSystemDefaults; + const admin = path.join(tmpRoot, 'broken-defaults.json'); + fs.writeFileSync(admin, '{ not json'); + a.agentEnv = { ...a.agentEnv, QWEN_CODE_SYSTEM_DEFAULTS_PATH: admin }; + assert.equal(a._writeSystemDefaults(), null); + }); +}); + +describe('QwenCodeAdapter — preflight', () => { + it('refuses a CLI below the version that takes this argv', async () => { + const a = makeAdapter({ fakeVersion: '0.15.0' }); + await send(a); + assert.equal(a._captured.error.length, 1); + assert.match(a._captured.error[0], /below the minimum supported version 0\.16\.0/); + assert.match(a._captured.error[0], /npm install -g @qwen-code\/qwen-code/); + }); + + it('names the install command when the CLI is missing entirely', async () => { + const a = makeAdapter(); + a._findQwenBinary = () => null; + await send(a); + assert.match(a._captured.error[0], /npm install -g @qwen-code\/qwen-code/); + }); + + it('refuses to run against a working directory that does not exist', async () => { + const a = makeAdapter({ workingDir: path.join(tmpRoot, 'no-such-dir') }); + await send(a); + assert.match(a._captured.error[0], /Working directory does not exist/); + }); +}); diff --git a/packages/agent-connector/test/stop-control.test.js b/packages/agent-connector/test/stop-control.test.js index 9cbe9db22..0f1ff9025 100644 --- a/packages/agent-connector/test/stop-control.test.js +++ b/packages/agent-connector/test/stop-control.test.js @@ -813,7 +813,7 @@ const { createAdapter } = require('../src/adapters'); const PROCESS_ADAPTERS = [ 'claude', 'codex', 'kimi', 'aider', 'amp', 'antigravity', 'cline', 'codebuddy', 'commandcode', 'copilot', 'cursor', 'deepseek', 'gemini', 'goose', 'hermes', - 'mini-swe-agent', 'opencode', 'openworker', 'pi', + 'mini-swe-agent', 'opencode', 'openworker', 'pi', 'qwen-code', ]; function fakeProc(pid) { @@ -1045,6 +1045,10 @@ const START_RUN = { start: (a, ch) => a._runCodeBuddy(ch, MISSING_BIN, [], require('node:os').tmpdir(), 'p'), stopped: { userStopped: true }, }, + 'qwen-code': { + start: (a, ch) => a._runQwen(ch, MISSING_BIN, [], require('node:os').tmpdir(), 'p'), + stopped: { userStopped: true }, + }, copilot: { start: (a, ch) => { a._copilotBin = MISSING_BIN; return a._runTurn(ch, []); }, stopped: { userStopped: true } }, goose: { start: (a, ch) => { diff --git a/packages/launcher/src/main/agents/auth-specs.ts b/packages/launcher/src/main/agents/auth-specs.ts index e555150d8..93f434ce9 100644 --- a/packages/launcher/src/main/agents/auth-specs.ts +++ b/packages/launcher/src/main/agents/auth-specs.ts @@ -1016,6 +1016,14 @@ export const CORE_AGENTS: readonly string[] = [ // installed core's adapter map, so a core without the codearts adapter // degrades to "unsupported" rather than a broken install. "codearts", + // Qwen Code (`qwen-code`): Alibaba's terminal coding agent, npm package + // @qwen-code/qwen-code (binary `qwen`). It authenticates with an + // OpenAI-compatible key only — `qwen auth` was removed upstream and its + // OAuth sign-in is gone — so there is no login command or sign-in probe: + // the registry's LLM_* fields (mapped to OPENAI_*) are the whole of it. + // Same core-before-marketplace ordering as above: a core without the + // qwen-code adapter degrades to "unsupported", not a broken install. + "qwen-code", // NanoClaw is intentionally NOT in this set: it's a BETA external // containerized runtime bridged via a native NanoClaw `openagents` channel, // so it stays "coming soon" (visible but not installable) and out of diff --git a/packages/launcher/src/main/agents/model-catalog.ts b/packages/launcher/src/main/agents/model-catalog.ts index 6e0395fed..ba8f991b1 100644 --- a/packages/launcher/src/main/agents/model-catalog.ts +++ b/packages/launcher/src/main/agents/model-catalog.ts @@ -484,6 +484,15 @@ const MODEL_SOURCES: Record = { keyVars: ["LLM_API_KEY"], baseVars: ["LLM_BASE_URL"], }, + // Qwen Code's adapter sends a blank base URL to Model Studio, so the + // picker lists from there too (its /models answers with the key's models). + "qwen-code": { + envVar: "LLM_MODEL", + provider: "openai", + keyVars: ["LLM_API_KEY"], + baseVars: ["LLM_BASE_URL"], + defaultBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", + }, opencode: { envVar: "LLM_MODEL", provider: "openai", diff --git a/packages/launcher/src/renderer/components/AgentIcon.tsx b/packages/launcher/src/renderer/components/AgentIcon.tsx index cf3f4c4e5..78b0fd5cf 100644 --- a/packages/launcher/src/renderer/components/AgentIcon.tsx +++ b/packages/launcher/src/renderer/components/AgentIcon.tsx @@ -25,6 +25,7 @@ const BUNDLED_SLUGS = new Set([ "opencode", "openworker", "pi", + "qwen-code", "yaml-agent", ]) diff --git a/packages/launcher/src/renderer/i18n/locales/en/agentMeta.json b/packages/launcher/src/renderer/i18n/locales/en/agentMeta.json index 4bc03514c..da0dfa60b 100644 --- a/packages/launcher/src/renderer/i18n/locales/en/agentMeta.json +++ b/packages/launcher/src/renderer/i18n/locales/en/agentMeta.json @@ -21,7 +21,8 @@ "commandcode": "Terminal coding agent that learns your coding taste — run headless, one session per channel.", "openworker": "Local-first agent coworker — $t(common.appName) runs its server headlessly, one session per channel.", "codebuddy": "Tencent's CodeBuddy Code CLI — the same agent engine the WorkBuddy desktop app runs, driven headlessly.", - "codearts": "Huawei Cloud's CodeArts code agent CLI (码道), driven headlessly with a Huawei Cloud access key." + "codearts": "Huawei Cloud's CodeArts code agent CLI (码道), driven headlessly with a Huawei Cloud access key.", + "qwen-code": "Alibaba's Qwen Code terminal coding agent, driven headlessly with a Model Studio or Coding Plan key — one session per channel." }, "genericLabels": { "apiKey": "API key", diff --git a/packages/launcher/src/renderer/i18n/locales/zh/agentMeta.json b/packages/launcher/src/renderer/i18n/locales/zh/agentMeta.json index 87b01518a..77db8b9e2 100644 --- a/packages/launcher/src/renderer/i18n/locales/zh/agentMeta.json +++ b/packages/launcher/src/renderer/i18n/locales/zh/agentMeta.json @@ -21,7 +21,8 @@ "commandcode": "会学习你代码风格的终端编码智能体 —— 以 headless 模式运行,每个频道一个会话。", "openworker": "本地优先的 AI 同事 —— $t(common.appName) 以无界面方式拉起它的服务,每个频道一个会话。", "codebuddy": "腾讯 CodeBuddy Code 命令行工具 —— 与 WorkBuddy 桌面端同源的智能体引擎,以 headless 模式驱动。", - "codearts": "华为云码道 CodeArts 代码智能体命令行工具,使用华为云访问密钥以 headless 模式驱动。" + "codearts": "华为云码道 CodeArts 代码智能体命令行工具,使用华为云访问密钥以 headless 模式驱动。", + "qwen-code": "阿里通义 Qwen Code 终端编程智能体,使用百炼(Model Studio)或 Coding Plan 的 API Key 以 headless 模式驱动 —— 每个频道一个会话。" }, "genericLabels": { "apiKey": "API 密钥", diff --git a/packages/launcher/src/renderer/public/icons/qwen-code.svg b/packages/launcher/src/renderer/public/icons/qwen-code.svg new file mode 100644 index 000000000..fd63482d2 --- /dev/null +++ b/packages/launcher/src/renderer/public/icons/qwen-code.svg @@ -0,0 +1 @@ +Qwen Code diff --git a/packages/launcher/src/shared/agent-credentials.ts b/packages/launcher/src/shared/agent-credentials.ts index e2c11a0f8..0e3065970 100644 --- a/packages/launcher/src/shared/agent-credentials.ts +++ b/packages/launcher/src/shared/agent-credentials.ts @@ -160,6 +160,7 @@ export const MODEL_LIST_AGENTS: ReadonlySet = new Set([ "opencode", "openworker", "pi", + "qwen-code", ]) /** True when offering a "Test connection" button would be a dead end. */ diff --git a/registry/icons/qwen-code.svg b/registry/icons/qwen-code.svg new file mode 100644 index 000000000..fd63482d2 --- /dev/null +++ b/registry/icons/qwen-code.svg @@ -0,0 +1 @@ +Qwen Code diff --git a/registry/index.json b/registry/index.json index 73de0f263..af9ced1e2 100644 --- a/registry/index.json +++ b/registry/index.json @@ -20,5 +20,6 @@ "commandcode", "openworker", "codebuddy", - "codearts" + "codearts", + "qwen-code" ] diff --git a/registry/qwen-code.json b/registry/qwen-code.json new file mode 100644 index 000000000..ec673c654 --- /dev/null +++ b/registry/qwen-code.json @@ -0,0 +1,94 @@ +{ + "name": "qwen-code", + "label": "Qwen Code", + "description": "Alibaba's open-source terminal coding agent for Qwen models, driven headless", + "homepage": "https://github.com/QwenLM/qwen-code", + "tags": [ + "coding", + "cli", + "open-source", + "headless" + ], + "featured": false, + "order": 999, + "builtin": true, + "logo": { + "key": "qwen-code" + }, + "support": { + "install": true, + "workspace": true, + "collaboration": true + }, + "install": { + "binary": "qwen", + "npm_package": "@qwen-code/qwen-code", + "requires": [ + "nodejs" + ], + "verify": "qwen --version", + "verify_win": "qwen --version", + "macos": "npm install -g @qwen-code/qwen-code", + "linux": "npm install -g @qwen-code/qwen-code", + "windows": "npm install -g @qwen-code/qwen-code" + }, + "uninstall": { + "macos": "npm uninstall -g @qwen-code/qwen-code", + "linux": "npm uninstall -g @qwen-code/qwen-code", + "windows": "npm uninstall -g @qwen-code/qwen-code" + }, + "adapter": { + "module": "openagents.adapters.qwen_code", + "class": "QwenCodeAdapter" + }, + "launch": null, + "check_ready": { + "min_version": "0.16.0", + "env_vars": [ + "OPENAI_API_KEY" + ], + "saved_env_key": "LLM_API_KEY", + "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + }, + "env_config": [ + { + "name": "LLM_API_KEY", + "description": "API key for an OpenAI-compatible endpoint — a Model Studio (DashScope) key, or a Bailian Coding Plan key together with its own base URL.", + "required": true, + "password": true + }, + { + "name": "LLM_BASE_URL", + "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", + "required": false, + "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "name": "LLM_MODEL", + "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", + "required": false, + "placeholder": "qwen3.8-flash" + } + ], + "catalog": true, + "workspace_model": true, + "resolve_env": { + "rules": [ + { + "from": "LLM_API_KEY", + "to": "OPENAI_API_KEY" + }, + { + "from": "LLM_BASE_URL", + "to": "OPENAI_BASE_URL" + }, + { + "from": "LLM_MODEL", + "to": "OPENAI_MODEL" + } + ] + }, + "vendor": "Alibaba Qwen", + "tagline": "Qwen's terminal coding agent, on your Model Studio or Coding Plan key." +} diff --git a/workspace/backend/registry/icons/qwen-code.svg b/workspace/backend/registry/icons/qwen-code.svg new file mode 100644 index 000000000..fd63482d2 --- /dev/null +++ b/workspace/backend/registry/icons/qwen-code.svg @@ -0,0 +1 @@ +Qwen Code diff --git a/workspace/backend/registry/index.json b/workspace/backend/registry/index.json index 73de0f263..af9ced1e2 100644 --- a/workspace/backend/registry/index.json +++ b/workspace/backend/registry/index.json @@ -20,5 +20,6 @@ "commandcode", "openworker", "codebuddy", - "codearts" + "codearts", + "qwen-code" ] diff --git a/workspace/backend/registry/qwen-code.json b/workspace/backend/registry/qwen-code.json new file mode 100644 index 000000000..ec673c654 --- /dev/null +++ b/workspace/backend/registry/qwen-code.json @@ -0,0 +1,94 @@ +{ + "name": "qwen-code", + "label": "Qwen Code", + "description": "Alibaba's open-source terminal coding agent for Qwen models, driven headless", + "homepage": "https://github.com/QwenLM/qwen-code", + "tags": [ + "coding", + "cli", + "open-source", + "headless" + ], + "featured": false, + "order": 999, + "builtin": true, + "logo": { + "key": "qwen-code" + }, + "support": { + "install": true, + "workspace": true, + "collaboration": true + }, + "install": { + "binary": "qwen", + "npm_package": "@qwen-code/qwen-code", + "requires": [ + "nodejs" + ], + "verify": "qwen --version", + "verify_win": "qwen --version", + "macos": "npm install -g @qwen-code/qwen-code", + "linux": "npm install -g @qwen-code/qwen-code", + "windows": "npm install -g @qwen-code/qwen-code" + }, + "uninstall": { + "macos": "npm uninstall -g @qwen-code/qwen-code", + "linux": "npm uninstall -g @qwen-code/qwen-code", + "windows": "npm uninstall -g @qwen-code/qwen-code" + }, + "adapter": { + "module": "openagents.adapters.qwen_code", + "class": "QwenCodeAdapter" + }, + "launch": null, + "check_ready": { + "min_version": "0.16.0", + "env_vars": [ + "OPENAI_API_KEY" + ], + "saved_env_key": "LLM_API_KEY", + "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + }, + "env_config": [ + { + "name": "LLM_API_KEY", + "description": "API key for an OpenAI-compatible endpoint — a Model Studio (DashScope) key, or a Bailian Coding Plan key together with its own base URL.", + "required": true, + "password": true + }, + { + "name": "LLM_BASE_URL", + "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", + "required": false, + "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "name": "LLM_MODEL", + "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", + "required": false, + "placeholder": "qwen3.8-flash" + } + ], + "catalog": true, + "workspace_model": true, + "resolve_env": { + "rules": [ + { + "from": "LLM_API_KEY", + "to": "OPENAI_API_KEY" + }, + { + "from": "LLM_BASE_URL", + "to": "OPENAI_BASE_URL" + }, + { + "from": "LLM_MODEL", + "to": "OPENAI_MODEL" + } + ] + }, + "vendor": "Alibaba Qwen", + "tagline": "Qwen's terminal coding agent, on your Model Studio or Coding Plan key." +} diff --git a/workspace/frontend/components/connect/connect-agent-view.tsx b/workspace/frontend/components/connect/connect-agent-view.tsx index 4ced71486..996092c3c 100644 --- a/workspace/frontend/components/connect/connect-agent-view.tsx +++ b/workspace/frontend/components/connect/connect-agent-view.tsx @@ -69,6 +69,7 @@ const AGENT_BRANDS: Record = { openworker: { bg: 'bg-blue-600', text: 'text-white' }, codebuddy: { bg: 'bg-indigo-600', text: 'text-white' }, codearts: { bg: 'bg-red-600', text: 'text-white' }, + 'qwen-code': { bg: 'bg-violet-700', text: 'text-white' }, }; const PROVIDER_BRANDS: Record = { diff --git a/workspace/frontend/public/icons/agents/qwen-code.svg b/workspace/frontend/public/icons/agents/qwen-code.svg new file mode 100644 index 000000000..fd63482d2 --- /dev/null +++ b/workspace/frontend/public/icons/agents/qwen-code.svg @@ -0,0 +1 @@ +Qwen Code From 9f5377d4b29769ebd146a270655ead0a650480f9 Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Wed, 14 Oct 2026 11:41:56 +0000 Subject: [PATCH 2/3] fix(qwen-code) require the model and base URL, and touch only the settings a run needs The model and base URL are now required fields, with the base URL prefilled with Model Studio China. - Left blank, the CLI runs qwen3.7-max, its most expensive tier, which a Coding Plan account may not offer at all. - The launcher's connection test probes what the form holds. A blank model made it try the endpoint's first listed model, which this key was not entitled to (a 403 that reads as a broken key), and a blank base URL made it probe api.openai.com while the agent itself would have used Model Studio. With both fields required, the test and the agent use the same endpoint and model. The workspace's own agent form can still create the agent with only a key, so the adapter keeps its fallbacks (Model Studio for a blank base URL, the CLI's default model). The settings layer no longer turns off usage statistics. It changes only what a headless run depends on, auto-memory and self-update, and leaves the rest to the user. The CLI writes a "$version" stamp back into the settings file it loads. The adapter rewrote the file whenever its text differed, so every turn replaced the file and every run migrated and wrote it again, while another channel's run could be reading it. The comparison now ignores that stamp, and the file is written once. Verified again end to end with a real Model Studio key against a local workspace backend, all checks passing, including the settings file staying untouched after the first turn. --- docs/agents/qwen-code.md | 26 ++++++++++++-- packages/agent-connector/registry.json | 10 +++--- .../src/adapters/qwen-code-stream.js | 36 +++++++++++++++---- .../agent-connector/src/adapters/qwen-code.js | 10 ++++-- .../test/qwen-code-stream.test.js | 15 ++++++++ .../agent-connector/test/qwen-code.test.js | 31 ++++++++++++++++ registry/qwen-code.json | 10 +++--- workspace/backend/registry/qwen-code.json | 10 +++--- 8 files changed, 120 insertions(+), 28 deletions(-) diff --git a/docs/agents/qwen-code.md b/docs/agents/qwen-code.md index f86c5813a..2a0d0dbc4 100644 --- a/docs/agents/qwen-code.md +++ b/docs/agents/qwen-code.md @@ -48,8 +48,22 @@ OAuth login. The agent runs on an OpenAI-compatible key: | Field | Passed to the CLI as | Notes | |---|---|---| | `LLM_API_KEY` | `OPENAI_API_KEY` (environment) | Required. Never put in argv. | -| `LLM_BASE_URL` | `OPENAI_BASE_URL` | Blank means Model Studio China, `https://dashscope.aliyuncs.com/compatible-mode/v1`. | -| `LLM_MODEL` | `--model` and `OPENAI_MODEL` | Blank means the CLI's default (`qwen3.7-max` on Model Studio). The workspace's model picker overrides it. | +| `LLM_BASE_URL` | `OPENAI_BASE_URL` | Required, prefilled with Model Studio China, `https://dashscope.aliyuncs.com/compatible-mode/v1`. | +| `LLM_MODEL` | `--model` and `OPENAI_MODEL` | Required. The launcher's picker lists what the base URL serves. The workspace's model picker overrides it. | + +Both the base URL and the model are required in the launcher, for two reasons: + +- Left to itself, the CLI runs `qwen3.7-max`, the most expensive tier, and a + Coding Plan account may not offer it at all. +- The launcher's "Test connection" probes whatever the form holds. With a + blank model it tried the endpoint's first listed model, which an account may + not be entitled to (a 403 that reads as a broken key). With a blank base URL + it probed api.openai.com, while the agent itself would have used Model + Studio. + +The workspace's own agent form can still create the agent with only a key +(the model left on "Auto"). The adapter then sends a blank base URL to Model +Studio and lets the CLI pick its default model. Base URLs for the common keys: @@ -90,7 +104,13 @@ project's `.qwen/settings.json` still override it. | `memory.enableManagedAutoMemory` | `false` | See below. | | `memory.enableManagedAutoDream` | `false` | Background memory consolidation, same reason. | | `general.enableAutoUpdate` | `false` | A self-update would replace the CLI, and the stream format the adapter parses, in the middle of a run. | -| `privacy.usageStatisticsEnabled` | `false` | The agent is not a person. | + +Nothing else is changed. Settings a headless run does not depend on, such as +usage statistics, are left to the user. + +The CLI writes a `"$version"` stamp back into this file the first time it +loads it. The adapter treats that stamp as no change, so the file is written +once rather than on every turn. **Why auto-memory is off.** When it is on, every turn ends with a "memory extractor" subagent, and the run's `result` frame waits for it to diff --git a/packages/agent-connector/registry.json b/packages/agent-connector/registry.json index 31ed30c76..4cb28dbb6 100644 --- a/packages/agent-connector/registry.json +++ b/packages/agent-connector/registry.json @@ -2355,7 +2355,7 @@ "OPENAI_API_KEY" ], "saved_env_key": "LLM_API_KEY", - "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + "not_ready_message": "Not configured — set an API key and a model (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the prefilled base URL." }, "env_config": [ { @@ -2366,15 +2366,15 @@ }, { "name": "LLM_BASE_URL", - "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", - "required": false, + "description": "OpenAI-compatible base URL, prefilled with Model Studio China. International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1 (international: https://coding-intl.dashscope.aliyuncs.com/v1)", + "required": true, "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" }, { "name": "LLM_MODEL", - "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", - "required": false, + "description": "Model id, e.g. qwen3.8-flash. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, "placeholder": "qwen3.8-flash" } ], diff --git a/packages/agent-connector/src/adapters/qwen-code-stream.js b/packages/agent-connector/src/adapters/qwen-code-stream.js index d6b7d4be8..98fd08e32 100644 --- a/packages/agent-connector/src/adapters/qwen-code-stream.js +++ b/packages/agent-connector/src/adapters/qwen-code-stream.js @@ -239,9 +239,11 @@ function buildQwenChildEnv(baseEnv = {}, { creds = {}, defaultsPath = '' } = {}) /** * Settings this adapter needs that have no command-line flag. * - * Loaded as Qwen Code's lowest-precedence "system defaults" scope, so the - * user's own settings.json (and a project's .qwen/settings.json) still win: - * someone who wants auto-memory back turns it on there. + * Only what a headless run depends on is here — anything else is the user's + * call, not the adapter's. Loaded as Qwen Code's lowest-precedence "system + * defaults" scope, so the user's own settings.json (and a project's + * .qwen/settings.json) still win: someone who wants auto-memory back turns it + * on there. */ const QWEN_SETTINGS_DEFAULTS = { memory: { @@ -256,10 +258,6 @@ const QWEN_SETTINGS_DEFAULTS = { enableAutoUpdate: false, disableAutoUpdate: true, }, - privacy: { - // The agent is not a person; nothing here should phone home about it. - usageStatisticsEnabled: false, - }, }; function isPlainObject(v) { @@ -282,6 +280,29 @@ function buildQwenSystemDefaults(existing = null) { return deepMerge(QWEN_SETTINGS_DEFAULTS, isPlainObject(existing) ? existing : {}); } +function canonicalJson(v) { + if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`; + if (isPlainObject(v)) { + return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`; + } + return JSON.stringify(v); +} + +/** + * True when a settings file already holds `desired`. + * + * The CLI migrates any settings file it loads and writes the result back, + * stamping a top-level `$version` (verified: our file came back with + * `"$version": 4`). That stamp is not a difference — rewriting over it would + * only make the CLI migrate and write again on the next run, while another + * channel's run may be reading the file. + */ +function sameQwenSettings(onDisk, desired) { + if (!isPlainObject(onDisk) || !isPlainObject(desired)) return false; + const strip = (o) => { const { $version, ...rest } = o; return rest; }; + return canonicalJson(strip(onDisk)) === canonicalJson(strip(desired)); +} + /** * Where the CLI would read its system-defaults file from, so a machine-wide * one is merged rather than replaced. Mirrors the CLI's own lookup: an @@ -668,6 +689,7 @@ module.exports = { resolveQwenCredentials, buildQwenChildEnv, buildQwenSystemDefaults, + sameQwenSettings, qwenSystemDefaultsSource, buildQwenArgs, parseFrame, diff --git a/packages/agent-connector/src/adapters/qwen-code.js b/packages/agent-connector/src/adapters/qwen-code.js index 84a955845..877cb0232 100644 --- a/packages/agent-connector/src/adapters/qwen-code.js +++ b/packages/agent-connector/src/adapters/qwen-code.js @@ -63,6 +63,7 @@ const { redactArgs, redactSecrets, resolveQwenCredentials, + sameQwenSettings, truncate, QWEN_MIN_VERSION, } = require('./qwen-code-stream'); @@ -537,10 +538,13 @@ class QwenCodeAdapter extends BaseAdapter { return null; } } - const content = JSON.stringify(buildQwenSystemDefaults(existing), null, 2); + const desired = buildQwenSystemDefaults(existing); let current = null; - try { current = fs.readFileSync(target, 'utf-8'); } catch {} - if (current !== content) { + try { current = JSON.parse(fs.readFileSync(target, 'utf-8')); } catch {} + // The CLI stamps `$version` into the file after loading it; only a real + // difference is worth a rewrite (see sameQwenSettings). + if (!sameQwenSettings(current, desired)) { + const content = JSON.stringify(desired, null, 2); fs.mkdirSync(path.dirname(target), { recursive: true }); // Atomic replace: another channel's run may be reading it right now. const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; diff --git a/packages/agent-connector/test/qwen-code-stream.test.js b/packages/agent-connector/test/qwen-code-stream.test.js index 3b6d15605..b68eacc15 100644 --- a/packages/agent-connector/test/qwen-code-stream.test.js +++ b/packages/agent-connector/test/qwen-code-stream.test.js @@ -16,6 +16,7 @@ const { parseFrame, qwenSystemDefaultsSource, redactArgs, + sameQwenSettings, redactSecrets, resolveQwenCredentials, } = require('../src/adapters/qwen-code-stream'); @@ -109,6 +110,11 @@ describe('qwen-code settings layer', () => { assert.equal(d.general.enableAutoUpdate, false); }); + it('changes nothing a headless run does not depend on', () => { + // Usage statistics and the like are the user's call. + assert.deepEqual(Object.keys(buildQwenSystemDefaults(null)).sort(), ['general', 'memory']); + }); + it("lets a machine-wide system-defaults file win key by key", () => { const d = buildQwenSystemDefaults({ memory: { enableManagedAutoMemory: true }, ui: { theme: 'x' } }); assert.equal(d.memory.enableManagedAutoMemory, true); @@ -116,6 +122,15 @@ describe('qwen-code settings layer', () => { assert.equal(d.ui.theme, 'x'); }); + it('treats the version stamp the CLI writes back as no change', () => { + const desired = buildQwenSystemDefaults(null); + const stamped = { ...JSON.parse(JSON.stringify(desired)), $version: 4 }; + assert.equal(sameQwenSettings(stamped, desired), true); + stamped.memory.enableManagedAutoMemory = true; + assert.equal(sameQwenSettings(stamped, desired), false); + assert.equal(sameQwenSettings(null, desired), false); + }); + it("finds the CLI's own system-defaults file", () => { assert.equal(qwenSystemDefaultsSource({}, 'linux'), '/etc/qwen-code/system-defaults.json'); assert.equal(qwenSystemDefaultsSource({}, 'darwin'), '/Library/Application Support/QwenCode/system-defaults.json'); diff --git a/packages/agent-connector/test/qwen-code.test.js b/packages/agent-connector/test/qwen-code.test.js index 1eb3e8861..857e92ba2 100644 --- a/packages/agent-connector/test/qwen-code.test.js +++ b/packages/agent-connector/test/qwen-code.test.js @@ -221,6 +221,17 @@ describe('QwenCodeAdapter — registration', () => { const rules = Object.fromEntries(entry.resolve_env.rules.map((r) => [r.from, r.to])); assert.deepEqual(rules, { LLM_API_KEY: 'OPENAI_API_KEY', LLM_BASE_URL: 'OPENAI_BASE_URL', LLM_MODEL: 'OPENAI_MODEL' }); }); + + it('asks for the model and base URL, with the base URL prefilled', () => { + // A blank model ran the CLI's most expensive default, and a blank field + // made the launcher's connection test probe something the agent never uses. + const fields = Object.fromEntries( + require('../registry.json').find((e) => e.name === 'qwen-code').env_config.map((f) => [f.name, f]), + ); + assert.equal(fields.LLM_MODEL.required, true); + assert.equal(fields.LLM_BASE_URL.required, true); + assert.equal(fields.LLM_BASE_URL.default, 'https://dashscope.aliyuncs.com/compatible-mode/v1'); + }); }); describe('QwenCodeAdapter — headless invocation', () => { @@ -485,6 +496,26 @@ describe('QwenCodeAdapter — settings layer file', () => { } }); + it('leaves the file alone once the CLI has stamped its version into it', () => { + // The CLI migrates the settings file it loads and writes "$version" back. + // Rewriting over that every run would make it migrate and write again. + const a = makeAdapter(); + delete a._writeSystemDefaults; + const home = path.join(tmpRoot, 'home-stamp'); + const realHome = os.homedir; + os.homedir = () => home; + try { + const target = a._writeSystemDefaults(); + const stamped = { ...JSON.parse(fs.readFileSync(target, 'utf-8')), $version: 4 }; + fs.writeFileSync(target, JSON.stringify(stamped)); + const before = fs.readFileSync(target, 'utf-8'); + assert.equal(a._writeSystemDefaults(), target); + assert.equal(fs.readFileSync(target, 'utf-8'), before); + } finally { + os.homedir = realHome; + } + }); + it("leaves an administrator's unreadable defaults file in charge", () => { const a = makeAdapter(); delete a._writeSystemDefaults; diff --git a/registry/qwen-code.json b/registry/qwen-code.json index ec673c654..510813680 100644 --- a/registry/qwen-code.json +++ b/registry/qwen-code.json @@ -48,7 +48,7 @@ "OPENAI_API_KEY" ], "saved_env_key": "LLM_API_KEY", - "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + "not_ready_message": "Not configured — set an API key and a model (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the prefilled base URL." }, "env_config": [ { @@ -59,15 +59,15 @@ }, { "name": "LLM_BASE_URL", - "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", - "required": false, + "description": "OpenAI-compatible base URL, prefilled with Model Studio China. International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1 (international: https://coding-intl.dashscope.aliyuncs.com/v1)", + "required": true, "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" }, { "name": "LLM_MODEL", - "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", - "required": false, + "description": "Model id, e.g. qwen3.8-flash. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, "placeholder": "qwen3.8-flash" } ], diff --git a/workspace/backend/registry/qwen-code.json b/workspace/backend/registry/qwen-code.json index ec673c654..510813680 100644 --- a/workspace/backend/registry/qwen-code.json +++ b/workspace/backend/registry/qwen-code.json @@ -48,7 +48,7 @@ "OPENAI_API_KEY" ], "saved_env_key": "LLM_API_KEY", - "not_ready_message": "Not configured — set an API key (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the default base URL." + "not_ready_message": "Not configured — set an API key and a model (press e). Qwen Code has no CLI sign-in; a Model Studio (DashScope) key works with the prefilled base URL." }, "env_config": [ { @@ -59,15 +59,15 @@ }, { "name": "LLM_BASE_URL", - "description": "OpenAI-compatible base URL. Model Studio China: https://dashscope.aliyuncs.com/compatible-mode/v1 — International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1", - "required": false, + "description": "OpenAI-compatible base URL, prefilled with Model Studio China. International: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 — Coding Plan: https://coding.dashscope.aliyuncs.com/v1 (international: https://coding-intl.dashscope.aliyuncs.com/v1)", + "required": true, "default": "https://dashscope.aliyuncs.com/compatible-mode/v1", "placeholder": "https://dashscope.aliyuncs.com/compatible-mode/v1" }, { "name": "LLM_MODEL", - "description": "Model id, e.g. qwen3.8-flash or qwen3.7-max. Leave blank for the CLI's default.", - "required": false, + "description": "Model id, e.g. qwen3.8-flash. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, "placeholder": "qwen3.8-flash" } ], From 032d6b3ead88349b0c37b8bc810e555b93871f39 Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Wed, 14 Oct 2026 12:59:58 +0000 Subject: [PATCH 3/3] fix(qwen-code): isolate credentials and preserve channel context Fix saved-provider credential routing, UTF-8 stream decoding, legacy API error results, and pinned channel context. Preserve system settings and clean legacy migration backups, with mocked and real CLI regression coverage. --- docs/agents/qwen-code.md | 24 +++- .../src/adapters/qwen-code-stream.js | 47 ++++++-- .../agent-connector/src/adapters/qwen-code.js | 54 ++++++++- .../test/qwen-code-cli.test.js | 101 ++++++++++++++++ .../test/qwen-code-stream.test.js | 32 +++++- .../agent-connector/test/qwen-code.test.js | 108 +++++++++++++++++- 6 files changed, 347 insertions(+), 19 deletions(-) create mode 100644 packages/agent-connector/test/qwen-code-cli.test.js diff --git a/docs/agents/qwen-code.md b/docs/agents/qwen-code.md index 2a0d0dbc4..6762d8a80 100644 --- a/docs/agents/qwen-code.md +++ b/docs/agents/qwen-code.md @@ -27,6 +27,10 @@ with tool results, and one closing `result` frame. The reply is read from `result.result`. Assistant text and tool calls only drive the channel's progress ticker. +Older supported releases such as 0.16.0 put `[API Error: ...]` in a successful +`result` and exit 0. The adapter recognizes that form as a failure too, so an +authentication or model error is never posted as a completed answer. + Workspace tools (`workspace_read_file`, the shared browser, todos, …) come through the same stdio MCP server the Claude adapter uses, so the briefing is the same one too. Qwen Code defers MCP tools. The model looks one up with @@ -40,6 +44,9 @@ session ran in, so a session is only resumed from that same directory. If the CLI answers a resume with "No saved session found", the binding is dropped and the turn runs once more from scratch. +The channel's decision log and glossary are refreshed before each turn and +pinned into the system prompt, including resumed turns and fresh sessions. + ## Authentication Qwen Code has no CLI sign-in: `qwen auth` was removed upstream, along with its @@ -87,6 +94,17 @@ form, or all three from `OPENAI_*`. They are never mixed. Otherwise a blank form base URL would pick up an `OPENAI_BASE_URL` exported for some other tool and send the Qwen key there. +Saved Qwen `modelProviders` take precedence even over CLI arguments. When an +explicit key is supplied, a private per-run system-settings file clears that +provider map so the key and endpoint above stay together. Other machine-wide +system settings are preserved, and the temporary file is deleted after the +run. If the original system-settings file exists but cannot be read as JSONC, +the adapter reports an error before starting the CLI. User and project +settings files are not rewritten by the adapter. + +An `OPENAI_API_KEY` with no `OPENAI_BASE_URL` explicitly uses +`https://api.openai.com/v1`, so a saved endpoint cannot redirect that key. + When the agent has no key at all, nothing is overridden and the CLI's own `~/.qwen/settings.json` decides. A provider set up with `/auth` in an interactive session still works that way. @@ -105,8 +123,9 @@ project's `.qwen/settings.json` still override it. | `memory.enableManagedAutoDream` | `false` | Background memory consolidation, same reason. | | `general.enableAutoUpdate` | `false` | A self-update would replace the CLI, and the stream format the adapter parses, in the middle of a run. | -Nothing else is changed. Settings a headless run does not depend on, such as -usage statistics, are left to the user. +The separate per-run system-settings file only overrides `modelProviders` +when an explicit key is supplied. Settings a headless run does not depend on, +such as usage statistics, are left to the user. The CLI writes a `"$version"` stamp back into this file the first time it loads it. The adapter treats that stamp as no change, so the file is written @@ -171,6 +190,7 @@ command-line limit. A standalone install runs on the Node it ships with. | `~/.openagents/sessions/__qwen-code.json` | The CLI session id per channel, and the working directory it was created in. | | `~/.openagents/mcp-configs/qwen-code-*.json` | The workspace MCP config for one run. Written `0600` and deleted when the run ends, because it carries the workspace token. | | `~/.openagents/qwen-code/system-defaults.json` | The settings layer described above. | +| `~/.openagents/qwen-code/run-settings/*.json` | Private per-run settings that isolate explicit credentials from saved providers; deleted with any CLI migration backup when the run ends. | | `~/.qwen/projects//chats/.jsonl` | Qwen Code's own transcripts (`$QWEN_HOME` if set). | ## Troubleshooting diff --git a/packages/agent-connector/src/adapters/qwen-code-stream.js b/packages/agent-connector/src/adapters/qwen-code-stream.js index 98fd08e32..cc5bc0173 100644 --- a/packages/agent-connector/src/adapters/qwen-code-stream.js +++ b/packages/agent-connector/src/adapters/qwen-code-stream.js @@ -16,7 +16,9 @@ * The answer is `result.result`; assistant frames only drive the ticker. * 2. A FAILED RUN STILL WRITES A RESULT FRAME. Model and auth errors arrive * as `result` with `is_error: true` and `error.message`, exit code 1, and - * nothing on stderr. The same error is ALSO streamed first as assistant + * nothing on stderr in 0.25.1. 0.16.0 instead wraps an API error in a + * successful result and exits 0; that result is normalized below. + * The same error is ALSO streamed first as assistant * text ("[API Error: 401 …]"), which must not be mistaken for an answer. * Exit 130 is a SIGINT/SIGTERM stop (no result frame), 53 a turn cap and * 55 a budget abort. @@ -205,7 +207,8 @@ function resolveQwenCredentials(agentEnv = {}, workspaceModel = '') { * another tool can leak in. A blank base URL becomes Model Studio only for the * agent's own key: a key that came from OPENAI_* in the environment was * exported for some other tool, most likely for api.openai.com, and must never - * be sent to a different provider by default — it keeps the CLI's own default. + * be sent to a different provider by default — it explicitly uses OpenAI's + * default so saved settings cannot redirect that key either. * Without a key, the environment is left alone for the CLI's own settings. * * @param {object} baseEnv the agent's full environment (process env included) @@ -218,9 +221,8 @@ function buildQwenChildEnv(baseEnv = {}, { creds = {}, defaultsPath = '' } = {}) const env = { ...baseEnv }; if (creds.apiKey) { env.OPENAI_API_KEY = creds.apiKey; - const baseUrl = creds.baseUrl || (creds.source === 'agent' ? QWEN_DEFAULT_BASE_URL : ''); - if (baseUrl) env.OPENAI_BASE_URL = baseUrl; - else delete env.OPENAI_BASE_URL; + env.OPENAI_BASE_URL = creds.baseUrl || (creds.source === 'agent' + ? QWEN_DEFAULT_BASE_URL : 'https://api.openai.com/v1'); if (creds.model) env.OPENAI_MODEL = creds.model; else delete env.OPENAI_MODEL; } @@ -312,15 +314,30 @@ function sameQwenSettings(onDisk, desired) { function qwenSystemDefaultsSource(env = {}, platform = process.platform) { const explicit = String(env.QWEN_CODE_SYSTEM_DEFAULTS_PATH || '').trim(); if (explicit) return explicit; - const settings = String(env.QWEN_CODE_SYSTEM_SETTINGS_PATH || '').trim() - || (platform === 'darwin' ? '/Library/Application Support/QwenCode/settings.json' - : platform === 'win32' ? 'C:\\ProgramData\\qwen-code\\settings.json' - : '/etc/qwen-code/settings.json'); + const settings = qwenSystemSettingsSource(env, platform); const sep = platform === 'win32' ? '\\' : '/'; const cut = Math.max(settings.lastIndexOf('/'), settings.lastIndexOf('\\')); return (cut >= 0 ? settings.slice(0, cut) : '.') + sep + 'system-defaults.json'; } +function qwenSystemSettingsSource(env = {}, platform = process.platform) { + return String(env.QWEN_CODE_SYSTEM_SETTINGS_PATH || '').trim() + || (platform === 'darwin' ? '/Library/Application Support/QwenCode/settings.json' + : platform === 'win32' ? 'C:\\ProgramData\\qwen-code\\settings.json' + : '/etc/qwen-code/settings.json'); +} + +/** Match Qwen's settings reader: JSON with comments and an optional UTF-8 BOM. */ +function parseQwenSettings(source) { + const json = String(source).replace(/^\uFEFF/, '').replace( + /"(?:\\.|[^"\\])*"|\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g, + (token) => token.startsWith('"') ? token : token.replace(/[^\r\n]/g, ' '), + ); + const settings = JSON.parse(json); + if (!isPlainObject(settings)) throw new SyntaxError('Expected a settings object.'); + return settings; +} + // --------------------------------------------------------------------------- // argv // --------------------------------------------------------------------------- @@ -466,14 +483,18 @@ function interpretQwenFrame(frame) { if (frame.type === 'result') { const err = frame.error && typeof frame.error === 'object' ? frame.error : null; + const text = typeof frame.result === 'string' ? frame.result : ''; + // 0.16.0 wraps provider failures in a success result and exits 0. + const legacyError = frame.is_error !== true && API_ERROR_TEXT_RE.test(text.trim()) + ? redactSecrets(text) : ''; return { kind: 'result', subtype: typeof frame.subtype === 'string' ? frame.subtype : '', - isError: frame.is_error === true, + isError: frame.is_error === true || !!legacyError, // `result` is present on success and absent on failure; always a string // here so callers never have to guard it. - text: typeof frame.result === 'string' ? frame.result : '', - errorMessage: err && typeof err.message === 'string' ? redactSecrets(err.message) : '', + text: legacyError ? '' : text, + errorMessage: err && typeof err.message === 'string' ? redactSecrets(err.message) : legacyError, sessionId: typeof frame.session_id === 'string' ? frame.session_id : null, numTurns: Number.isFinite(frame.num_turns) ? frame.num_turns : null, durationMs: Number.isFinite(frame.duration_ms) ? frame.duration_ms : null, @@ -691,6 +712,8 @@ module.exports = { buildQwenSystemDefaults, sameQwenSettings, qwenSystemDefaultsSource, + qwenSystemSettingsSource, + parseQwenSettings, buildQwenArgs, parseFrame, toolInputPreview, diff --git a/packages/agent-connector/src/adapters/qwen-code.js b/packages/agent-connector/src/adapters/qwen-code.js index 877cb0232..91f032b4c 100644 --- a/packages/agent-connector/src/adapters/qwen-code.js +++ b/packages/agent-connector/src/adapters/qwen-code.js @@ -59,7 +59,9 @@ const { classifyQwenVersion, interpretQwenFrame, parseFrame, + parseQwenSettings, qwenSystemDefaultsSource, + qwenSystemSettingsSource, redactArgs, redactSecrets, resolveQwenCredentials, @@ -100,6 +102,7 @@ class QwenCodeAdapter extends BaseAdapter { constructor(opts) { super(opts); this.disabledModules = opts.disabledModules || new Set(); + this._usesPinnedContext = true; // channel → { sessionId, workingDir } this._channelSessions = {}; // channel → child process (the in-flight headless run) @@ -558,6 +561,27 @@ class QwenCodeAdapter extends BaseAdapter { } } + /** Keep saved modelProviders from redirecting this run's explicit credentials. */ + _writeRunSettings() { + const source = qwenSystemSettingsSource(this.agentEnv); + let existing = {}; + if (fs.existsSync(source)) { + try { + existing = parseQwenSettings(fs.readFileSync(source, 'utf-8')); + } catch { + throw new Error(`Cannot read system settings as JSON: ${source}`); + } + } + // System settings outrank user/project settings. null clears the map; + // an empty object would merge the saved providers back into this run. + const settings = { ...existing, modelProviders: null }; + const dir = path.join(os.homedir(), '.openagents', 'qwen-code', 'run-settings'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, JSON.stringify(settings), { mode: 0o600 }); + return file; + } + // ------------------------------------------------------------------ // Workspace MCP server // ------------------------------------------------------------------ @@ -821,9 +845,11 @@ class QwenCodeAdapter extends BaseAdapter { // seen on macOS); unhandled, that would take the daemon down. proc.stdout.on('error', () => {}); proc.stderr.on('error', () => {}); + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); proc.stdout.on('data', (chunk) => { - stdoutBuf += chunk.toString('utf-8'); + stdoutBuf += chunk; let nl; while ((nl = stdoutBuf.indexOf('\n')) !== -1) { const line = stdoutBuf.slice(0, nl); @@ -834,7 +860,7 @@ class QwenCodeAdapter extends BaseAdapter { proc.stderr.on('data', (chunk) => { // Bounded: a long run must not grow it without limit. - stderrBuf = truncate(stderrBuf + chunk.toString('utf-8'), 8000); + stderrBuf = truncate(stderrBuf + chunk, 8000); }); proc.on('error', (e) => { @@ -954,6 +980,7 @@ class QwenCodeAdapter extends BaseAdapter { model: creds.model || null, browserEnabled: await this.getBrowserEnabled(), toolMode: 'mcp', + ...this.pinnedPromptOpts(channel), }); const env = buildQwenChildEnv(getEnhancedEnv(this.agentEnv), { @@ -974,6 +1001,14 @@ class QwenCodeAdapter extends BaseAdapter { if (recap) prompt = `${recap}\n\n---\n\n${content}`; } + let runSettingsPath; + try { + if (creds.apiKey) runSettingsPath = this._writeRunSettings(); + } catch (e) { + await this.sendError(channel, `Could not prepare Qwen Code settings: ${redactSecrets(e.message)}`); + return; + } + const mcpConfigPath = this._writeMcpConfig(channel); const args = buildQwenArgs({ appendSystemPrompt: systemPrompt, @@ -986,9 +1021,22 @@ class QwenCodeAdapter extends BaseAdapter { let run; try { - run = await this._runQwen(channel, bin, args, workingDir, prompt, env); + const runEnv = runSettingsPath + ? { + ...env, + QWEN_CODE_SYSTEM_SETTINGS_PATH: runSettingsPath, + QWEN_CODE_SYSTEM_DEFAULTS_PATH: env.QWEN_CODE_SYSTEM_DEFAULTS_PATH + || qwenSystemDefaultsSource(this.agentEnv), + } : env; + run = await this._runQwen(channel, bin, args, workingDir, prompt, runEnv); } finally { if (mcpConfigPath) { try { fs.unlinkSync(mcpConfigPath); } catch {} } + if (runSettingsPath) { + // Older CLIs back up the settings file before migrating it. + for (const file of [runSettingsPath, `${runSettingsPath}.orig`]) { + try { fs.unlinkSync(file); } catch {} + } + } } if (run.userStopped) { diff --git a/packages/agent-connector/test/qwen-code-cli.test.js b/packages/agent-connector/test/qwen-code-cli.test.js new file mode 100644 index 000000000..bc3072271 --- /dev/null +++ b/packages/agent-connector/test/qwen-code-cli.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const { it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const QwenCodeAdapter = require('../src/adapters/qwen-code'); + +// Optional real-CLI regression: no external endpoint or paid key is used. +const cli = process.env.QWEN_CODE_TEST_CLI; + +for (const envKey of ['OPENAI_API_KEY', 'SAVED_PROVIDER_KEY']) { + it(`uses the agent endpoint and key over an existing provider (${envKey})`, { + skip: !cli && 'Set QWEN_CODE_TEST_CLI to a Qwen Code executable or JS entry', + timeout: 30000, + }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-qwen-cli-')); + const requests = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url, authorization: req.headers.authorization }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'test rejection', type: 'authentication_error' } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + let adapter; + try { + const base = `http://127.0.0.1:${server.address().port}`; + const qwenHome = path.join(dir, 'home'); + const projectConfig = path.join(dir, '.qwen'); + fs.mkdirSync(qwenHome); + fs.mkdirSync(projectConfig); + const saved = JSON.stringify({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { openai: [{ id: 'review-model', baseUrl: `${base}/saved/v1`, envKey }] }, + }); + const savedFile = path.join(qwenHome, 'settings.json'); + fs.writeFileSync(savedFile, saved); + fs.writeFileSync(path.join(projectConfig, 'settings.json'), saved); + const systemFile = path.join(dir, 'system.json'); + const systemSettings = '// machine policy\n' + JSON.stringify({ tools: { exclude: ['web_search'] } }); + fs.writeFileSync(systemFile, systemSettings); + const defaultsFile = path.join(dir, 'defaults.json'); + fs.writeFileSync(defaultsFile, JSON.stringify({ + memory: { enableManagedAutoMemory: false, enableManagedAutoDream: false }, + general: { enableAutoUpdate: false, disableAutoUpdate: true }, + })); + adapter = new QwenCodeAdapter({ + workspaceId: 'qwen-cli-test', channelName: 'thread', token: 'unused', + agentName: path.basename(dir), agentType: 'qwen-code', workingDir: dir, + agentEnv: { + ...process.env, + QWEN_HOME: qwenHome, + QWEN_CODE_SYSTEM_SETTINGS_PATH: systemFile, + QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsFile, + SAVED_PROVIDER_KEY: 'saved-provider-test-key', + LLM_API_KEY: 'agent-test-key', LLM_BASE_URL: `${base}/agent/v1`, LLM_MODEL: 'review-model', + }, + }); + const errors = []; + const responses = []; + let runSettingsPath; + const writeRunSettings = adapter._writeRunSettings.bind(adapter); + adapter._writeRunSettings = () => { + runSettingsPath = writeRunSettings(); + return runSettingsPath; + }; + adapter._log = () => {}; + adapter._findQwenBinary = () => cli; + adapter._writeMcpConfig = () => null; + adapter._writeSystemDefaults = () => defaultsFile; + adapter.getBrowserEnabled = async () => false; + adapter.sendStatus = async () => {}; + adapter.sendThinking = async () => {}; + adapter.sendError = async (_channel, text) => { errors.push(text); }; + adapter.sendResponse = async (_channel, text) => { responses.push(text); }; + adapter.client = { + getSession: async () => ({ title: 'Test', titleManuallySet: true }), + getRecentMessages: async () => [], + }; + await adapter._handleMessage({ content: 'Reply OK. Do not use tools.', sessionId: 'thread' }); + + assert.deepEqual(requests, [{ url: '/agent/v1/chat/completions', authorization: 'Bearer agent-test-key' }]); + assert.deepEqual(responses, []); + assert.equal(errors.length, 1); + assert.match(errors[0], /rejected the API key/); + // Qwen itself may migrate the file and add a version stamp. + const { $version, ...savedSettings } = JSON.parse(fs.readFileSync(savedFile, 'utf8')); + assert.deepEqual(savedSettings, JSON.parse(saved)); + assert.equal(fs.readFileSync(systemFile, 'utf8'), systemSettings); + assert.equal(fs.existsSync(runSettingsPath), false); + assert.equal(fs.existsSync(`${runSettingsPath}.orig`), false); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + if (adapter && fs.existsSync(adapter._sessionsFile)) fs.unlinkSync(adapter._sessionsFile); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} diff --git a/packages/agent-connector/test/qwen-code-stream.test.js b/packages/agent-connector/test/qwen-code-stream.test.js index b68eacc15..e1a7bd4c8 100644 --- a/packages/agent-connector/test/qwen-code-stream.test.js +++ b/packages/agent-connector/test/qwen-code-stream.test.js @@ -14,6 +14,7 @@ const { classifyQwenVersion, interpretQwenFrame, parseFrame, + parseQwenSettings, qwenSystemDefaultsSource, redactArgs, sameQwenSettings, @@ -76,7 +77,7 @@ describe('qwen-code credentials', () => { // blank base URL at DashScope would hand it to a third party. const c = resolveQwenCredentials({ OPENAI_API_KEY: 'sk-openai-key-123456' }); const env = buildQwenChildEnv({ OPENAI_API_KEY: 'sk-openai-key-123456' }, { creds: c }); - assert.equal(env.OPENAI_BASE_URL, undefined); + assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1'); }); it("puts the workspace's model picker ahead of the configured model", () => { @@ -103,6 +104,22 @@ describe('qwen-code credentials', () => { }); describe('qwen-code settings layer', () => { + it('reads JSONC settings without treating quoted URLs or escaped quotes as comments', () => { + const source = '\uFEFF{ // system policy\n' + + '"url": "https://example.com/path/*literal*/", /* keep URL */\n' + + '"label": "say \\"hello\\" // literal",\n' + + '"tools": {"exclude": ["web_search"]}\n}'; + assert.deepEqual(parseQwenSettings(source), { + url: 'https://example.com/path/*literal*/', + label: 'say "hello" // literal', + tools: { exclude: ['web_search'] }, + }); + assert.throws(() => parseQwenSettings('{"value":1/* comment */2}'), SyntaxError); + assert.throws(() => parseQwenSettings('{"value":1,}'), SyntaxError); + assert.throws(() => parseQwenSettings('null'), SyntaxError); + assert.throws(() => parseQwenSettings('[]'), SyntaxError); + }); + it('turns off auto-memory and self-update', () => { const d = buildQwenSystemDefaults(null); assert.equal(d.memory.enableManagedAutoMemory, false); @@ -241,6 +258,19 @@ describe('qwen-code frames', () => { assert.match(bad.errorMessage, /404/); }); + it('normalizes API errors wrapped as successful results by Qwen Code 0.16.0', () => { + for (const [message, kind] of [ + ['[API Error: 401 Incorrect API key provided.]', 'auth'], + ['[API Error: 404 The model does not exist.]', 'model'], + ]) { + const result = interpretQwenFrame({ type: 'result', subtype: 'success', is_error: false, result: message }); + assert.equal(result.isError, true); + assert.equal(result.text, ''); + assert.equal(result.errorMessage, message); + assert.equal(classifyQwenRun({ result, code: 0 }).kind, kind); + } + }); + it('flags a frame type it has never seen', () => { assert.equal(interpretQwenFrame({ type: 'brand_new' }).kind, 'unknown'); assert.equal(parseFrame('not json'), null); diff --git a/packages/agent-connector/test/qwen-code.test.js b/packages/agent-connector/test/qwen-code.test.js index 857e92ba2..8e3ca90af 100644 --- a/packages/agent-connector/test/qwen-code.test.js +++ b/packages/agent-connector/test/qwen-code.test.js @@ -48,11 +48,14 @@ process.stdin.on('data', (c) => { stdin += c; }); process.stdin.on('end', () => { if (process.env.FAKE_CAPTURE) { const pick = (k) => process.env[k] === undefined ? null : process.env[k]; - fs.writeFileSync(process.env.FAKE_CAPTURE, JSON.stringify({ args, stdin, cwd: process.cwd(), env: { + const settingsPath = process.env.QWEN_CODE_SYSTEM_SETTINGS_PATH; + const settings = settingsPath ? JSON.parse(fs.readFileSync(settingsPath, 'utf8')) : null; + fs.writeFileSync(process.env.FAKE_CAPTURE, JSON.stringify({ args, stdin, cwd: process.cwd(), settings, env: { OPENAI_API_KEY: pick('OPENAI_API_KEY'), OPENAI_BASE_URL: pick('OPENAI_BASE_URL'), OPENAI_MODEL: pick('OPENAI_MODEL'), QWEN_CODE_SYSTEM_DEFAULTS_PATH: pick('QWEN_CODE_SYSTEM_DEFAULTS_PATH'), + QWEN_CODE_SYSTEM_SETTINGS_PATH: pick('QWEN_CODE_SYSTEM_SETTINGS_PATH'), QWEN_CODE_SUPPRESS_YOLO_WARNING: pick('QWEN_CODE_SUPPRESS_YOLO_WARNING'), } })); } @@ -89,6 +92,22 @@ process.stdin.on('end', () => { result({ subtype: 'error_during_execution', is_error: true, error: { message: msg } }); process.exit(1); } + if (scenario === 'legacy_auth') { + init(); + const msg = '[API Error: 401 Incorrect API key provided.]'; + say({ type: 'text', text: msg }); + result({ subtype: 'success', is_error: false, result: msg }); + process.exit(0); + } + if (scenario === 'split_unicode') { + init(); + const bytes = Buffer.from(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, + session_id: sid, result: '你好,世界 🌍' }) + '\\n'); + const split = bytes.indexOf(Buffer.from('好')) + 1; + process.stdout.write(bytes.subarray(0, split)); + setTimeout(() => { process.stdout.write(bytes.subarray(split)); process.exit(0); }, 50); + return; + } if (scenario === 'stale_resume') { if (resumeAt >= 0) { process.stderr.write('No saved session found with ID ' + sid + '. Run \`qwen --resume\` without an ID to choose from existing sessions.\\n'); @@ -344,6 +363,21 @@ describe('QwenCodeAdapter — replies', () => { assert.match(a._captured.error[0], /Incorrect API key provided/); }); + it('reports a legacy CLI auth error even when its result and exit code say success', async () => { + const a = makeAdapter({ scenario: 'legacy_auth' }); + await send(a); + assert.deepEqual(a._captured.response, []); + assert.deepEqual(a._captured.thinking, []); + assert.equal(a._captured.error.length, 1); + assert.match(a._captured.error[0], /rejected the API key/); + }); + + it('preserves UTF-8 characters split across stdout chunks', async () => { + const a = makeAdapter({ scenario: 'split_unicode' }); + await send(a); + assert.deepEqual(a._captured.response, ['你好,世界 🌍']); + }); + it('falls back to the streamed text when a clean run summarizes nothing', async () => { const a = makeAdapter({ scenario: 'empty_result' }); await send(a); @@ -358,6 +392,32 @@ describe('QwenCodeAdapter — replies', () => { }); }); +describe('QwenCodeAdapter — pinned channel context', () => { + it('refreshes decisions and glossary in the system prompt on a resumed turn', async () => { + const a = makeAdapter(); + const entries = [ + { id: 'decisions-id', title: 'Decisions for channel thread', content: 'Use endpoint A.' }, + { id: 'glossary-id', title: 'Glossary for channel thread', content: 'Widget means a local task.' }, + ]; + a.client.listKnowledge = async () => ({ entries }); + a.client.getKnowledge = async (_ws, _token, id) => entries.find((e) => e.id === id); + + await a._channelWorker('thread', { content: 'first turn', sessionId: 'thread' }); + const first = argAfter(readCapture(a), '--append-system-prompt'); + assert.ok(first.includes('Use endpoint A.')); + assert.ok(first.includes('Widget means a local task.')); + assert.ok(first.includes('decisions-id')); + + entries[0].content = 'Use endpoint B.'; + await a._channelWorker('thread', { content: 'next turn', sessionId: 'thread' }); + const cap = readCapture(a); + const next = argAfter(cap, '--append-system-prompt'); + assert.equal(argAfter(cap, '--resume'), 'sess-abc'); + assert.ok(next.includes('Use endpoint B.')); + assert.ok(!next.includes('Use endpoint A.')); + }); +}); + describe('QwenCodeAdapter — session continuity', () => { it('resumes the channel session on the next turn', async () => { const a = makeAdapter(); @@ -476,6 +536,52 @@ describe('QwenCodeAdapter — binary resolution', () => { }); describe('QwenCodeAdapter — settings layer file', () => { + it('preserves system settings while isolating explicit credentials for one run', async () => { + const admin = path.join(tmpRoot, 'system-policy.json'); + const original = '{ // machine policy\n"tools":{"exclude":["web_search"]},"modelProviders":{"openai":[]}\n}'; + fs.writeFileSync(admin, original); + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'agent-key', QWEN_CODE_SYSTEM_SETTINGS_PATH: admin } }); + await send(a); + const cap = readCapture(a); + assert.deepEqual(cap.settings, { tools: { exclude: ['web_search'] }, modelProviders: null }); + assert.notEqual(cap.env.QWEN_CODE_SYSTEM_SETTINGS_PATH, admin); + assert.equal(fs.existsSync(cap.env.QWEN_CODE_SYSTEM_SETTINGS_PATH), false); + assert.equal(fs.readFileSync(admin, 'utf8'), original); + }); + + it('leaves a keyless run on the original system settings file', async () => { + const admin = path.join(tmpRoot, 'keyless-system.json'); + const settings = { modelProviders: { openai: [{ id: 'saved-model' }] } }; + fs.writeFileSync(admin, JSON.stringify(settings)); + const a = makeAdapter({ agentEnv: { QWEN_CODE_SYSTEM_SETTINGS_PATH: admin } }); + await send(a); + const cap = readCapture(a); + assert.equal(cap.env.QWEN_CODE_SYSTEM_SETTINGS_PATH, admin); + assert.deepEqual(cap.settings, settings); + assert.ok(fs.existsSync(admin)); + }); + + it('refuses to run if system settings cannot be preserved safely', async () => { + const admin = path.join(tmpRoot, 'invalid-system.json'); + fs.writeFileSync(admin, '{ invalid json'); + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'agent-key', QWEN_CODE_SYSTEM_SETTINGS_PATH: admin } }); + await send(a); + assert.equal(fs.existsSync(capturePath), false); + assert.deepEqual(a._captured.response, []); + assert.match(a._captured.error[0], /Could not prepare Qwen Code settings/); + }); + + it('keeps the original system-defaults lookup when its overlay cannot be written', async () => { + const admin = path.join(tmpRoot, 'policy', 'settings.json'); + fs.mkdirSync(path.dirname(admin), { recursive: true }); + fs.writeFileSync(admin, '{}'); + const a = makeAdapter({ agentEnv: { LLM_API_KEY: 'agent-key', QWEN_CODE_SYSTEM_SETTINGS_PATH: admin } }); + a._writeSystemDefaults = () => null; + await send(a); + assert.equal(readCapture(a).env.QWEN_CODE_SYSTEM_DEFAULTS_PATH, + path.join(tmpRoot, 'policy', 'system-defaults.json')); + }); + it('merges a machine-wide system-defaults file instead of replacing it', () => { const a = makeAdapter(); delete a._writeSystemDefaults;