diff --git a/docs/agents/qwen-code.md b/docs/agents/qwen-code.md new file mode 100644 index 000000000..6762d8a80 --- /dev/null +++ b/docs/agents/qwen-code.md @@ -0,0 +1,214 @@ +# 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. + +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 +`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. + +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 +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` | 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: + +| 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. + +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. + +## 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. | + +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 +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 +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. | +| `~/.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 + +| 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..4cb28dbb6 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 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": [ + { + "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, 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. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, + "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..cc5bc0173 --- /dev/null +++ b/packages/agent-connector/src/adapters/qwen-code-stream.js @@ -0,0 +1,724 @@ +/** + * 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 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. + * 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 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) + * @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; + 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; + } + 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. + * + * 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: { + // 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, + }, +}; + +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 : {}); +} + +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 + * 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 = 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 +// --------------------------------------------------------------------------- + +/** + * 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; + 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 || !!legacyError, + // `result` is present on success and absent on failure; always a string + // here so callers never have to guard it. + 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, + }; + } + + 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, + sameQwenSettings, + qwenSystemDefaultsSource, + qwenSystemSettingsSource, + parseQwenSettings, + 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..91f032b4c --- /dev/null +++ b/packages/agent-connector/src/adapters/qwen-code.js @@ -0,0 +1,1095 @@ +/** + * 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, + parseQwenSettings, + qwenSystemDefaultsSource, + qwenSystemSettingsSource, + redactArgs, + redactSecrets, + resolveQwenCredentials, + sameQwenSettings, + 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(); + this._usesPinnedContext = true; + // 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 desired = buildQwenSystemDefaults(existing); + let current = null; + 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`; + 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; + } + } + + /** 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 + // ------------------------------------------------------------------ + + /** + * 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.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + + proc.stdout.on('data', (chunk) => { + stdoutBuf += chunk; + 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, 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', + ...this.pinnedPromptOpts(channel), + }); + + 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}`; + } + + 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, + authType: creds.apiKey ? 'openai' : '', + model: creds.model, + resumeSessionId: resumeId || undefined, + mcpConfigPath: mcpConfigPath || undefined, + planMode: this._mode === 'plan', + }); + + let run; + try { + 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) { + // 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-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 new file mode 100644 index 000000000..e1a7bd4c8 --- /dev/null +++ b/packages/agent-connector/test/qwen-code-stream.test.js @@ -0,0 +1,325 @@ +'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, + parseQwenSettings, + qwenSystemDefaultsSource, + redactArgs, + sameQwenSettings, + 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, 'https://api.openai.com/v1'); + }); + + 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('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); + assert.equal(d.memory.enableManagedAutoDream, false); + 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); + assert.equal(d.memory.enableManagedAutoDream, false); + 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'); + 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('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); + }); +}); + +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..8e3ca90af --- /dev/null +++ b/packages/agent-connector/test/qwen-code.test.js @@ -0,0 +1,656 @@ +'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]; + 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'), + } })); + } + 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 === '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'); + 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' }); + }); + + 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', () => { + 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('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); + 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 — 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(); + 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('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; + 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 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; + 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..510813680 --- /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 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": [ + { + "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, 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. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, + "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..510813680 --- /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 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": [ + { + "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, 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. Pick one the base URL serves — Coding Plan offers a shorter list than Model Studio.", + "required": true, + "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