From 7c75c1dc1b9e3745c673daef7f4bd7dc2e7ca09d Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 24 Aug 2026 08:28:53 +0800 Subject: [PATCH] feat: Qwen Code backend (Alibaba) via @qwen-code/sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `qwen` as a session backend, modeled on ClaudeProvider rather than the ACP providers: @qwen-code/sdk is a deliberate clone of the Claude Agent SDK (query({prompt, options}), createSdkMcpServer(), tool()), so the warm-loop model maps across directly. qwen-code also speaks ACP, but that path has no in-process MCP mount and no typed auth selection. Both credential paths work, selected by providers.qwen.authType and auto-detected when unset (a ~/.qwen login wins over the key): - qwen-oauth — qwen.ai subscription; tokens never transit codeoid - openai — OpenAI-compatible key + gateway Gateway presets are included because the right one is not discoverable: a Model Studio *plan* key (sk-sp-...) is rejected by both standard DashScope hosts with invalid_api_key and needs token-plan.ap-southeast-1.maas.aliyuncs.com (`bailian-plan-intl`). Plan keys also expose more than Qwen — qwen3.8-max, glm-5.2, deepseek-v4-pro — so providers.qwen.model selects one. SECURITY — buildQwenEnv() returns more than an allowlist by design. Unlike the Claude SDK (which replaces the child env), @qwen-code/sdk spawns with {...process.env, ...options.env}. A plain allowlist is therefore a no-op: everything omitted is still inherited, including CODEOID_API_KEY (the root ZeroID key) and TELEGRAM_BOT_TOKEN. Since a merge can only add or override, every non-allowlisted name is mapped to the empty string. Verified end to end: the agent ran `echo "leak=[$CODEOID_API_KEY]"` and got `leak=[]`. Two divergences from ClaudeProvider forced by the SDK: - Tool correlation. Qwen's canUseTool gets only {signal, suggestions} — no toolUseID — so the id comes from the assistant message's tool_use block. Matching is oldest-first among ungated entries of the same NAME, never positional, since auto-approved tools never reach the callback (issue #81). - Backing ids are coerced to UUIDs. query() validates sessionId and throws SYNCHRONOUSLY on a non-UUID, which would escape runTurn() and wedge the session instead of surfacing as a turn error. Also: permissionMode is pinned to "default" (qwen-code's own ACP default is "auto", which silently auto-approves edits and shell), and turn_done falls back to the requested model because the gateway often omits modelUsage. Known gap, documented in CLAUDE.md: qwen-code's resolveDefaultPermission runs isShellCommandReadOnlyAST() and auto-allows anything it classifies read-only WITHOUT raising a permission request, so those never reach codeoid's gate. Measured: `rm -f` gates, but `echo`, `cat /etc/hostname`, and a decoy file in $HOME all ran ungated — including reads outside the workdir. codeoid cannot intercept a call the CLI never makes. Audited against gemini-cli 0.50.0 (the fork parent) and the mechanism appears qwen-specific; that check is static only, as the live ACP run was blocked on expired ~/.gemini OAuth. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 71 ++- bun.lock | 5 + package.json | 1 + src/config.ts | 56 ++ src/daemon/providers/env.ts | 41 ++ src/daemon/providers/qwen/index.ts | 830 +++++++++++++++++++++++++++++ src/daemon/providers/registry.ts | 24 + src/tests/provider-qwen.test.ts | 411 ++++++++++++++ 8 files changed, 1435 insertions(+), 4 deletions(-) create mode 100644 src/daemon/providers/qwen/index.ts create mode 100644 src/tests/provider-qwen.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c3d6dd5d..af2fab7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ codeoid start ├── Bun.serve() — HTTP + WebSocket server ├── ShutdownManager — cleanup registry, signal handlers, 30s grace period ├── SessionManager — rate limiting, session resume, scope enforcement - │ ├── ProviderRegistry — pluggable backends (claude default; openai, gemini, codex, pi, gemini-cli) + │ ├── ProviderRegistry — pluggable backends (claude default; openai, gemini, codex, pi, gemini-cli, qwen) │ └── Session × N — each drives one provider backend (Claude Agent SDK by default) │ ├── ScrollbackBuffer — circular ring, replayed on device handoff │ ├── TranscriptStore — JSONL persistence, survives daemon restart @@ -55,7 +55,7 @@ src/ │ ├── providers/ # Pluggable agent backends behind one SessionProvider interface │ │ ├── registry.ts # ProviderRegistry + createDefaultProviderRegistry() │ │ ├── interface.ts # SessionProvider contract -│ │ └── claude|openai|gemini|codex|pi|acp/ # one dir per backend (acp = Gemini CLI) +│ │ └── claude|openai|gemini|codex|pi|acp|qwen/ # one dir per backend (acp = Gemini CLI) │ ├── store.ts # bun:sqlite — sessions + audit_log tables │ ├── auth.ts # ZeroID JWT verification via @highflame/sdk │ ├── agent-identity.ts # ZeroID identities for coding agents + sub-agents @@ -81,7 +81,7 @@ src/ ## Tech Stack - **Runtime**: Bun (native WebSocket, bun:sqlite, Bun.serve()) -- **Agent backends**: pluggable via `ProviderRegistry` — Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`, default), plus OpenAI, Gemini, Codex, pi, and the Gemini CLI +- **Agent backends**: pluggable via `ProviderRegistry` — Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`, default), plus OpenAI, Gemini, Codex, pi, the Gemini CLI, and Qwen Code (`@qwen-code/sdk`) - **Auth**: ZeroID via `@highflame/sdk` (local JWKS verification) - **Telegram**: Grammy - **CLI**: Commander @@ -93,7 +93,7 @@ No native addon dependencies. Single `bun build` produces a 1.1MB bundle. ### Sessions A session = one agent backend (the Claude Agent SDK by default; also Codex, -Gemini, OpenAI, pi, or the Gemini CLI) working in one directory. +Gemini, OpenAI, pi, the Gemini CLI, or Qwen Code) working in one directory. Sessions are named, persistent, and daemon-owned. Multiple clients can attach/detach simultaneously from any frontend. @@ -177,6 +177,69 @@ TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token # Falls back to ANTHROPIC_API_KEY env var if not logged in. ``` +### Qwen backend (Alibaba) + +Qwen Code runs in-process via `@qwen-code/sdk`, which bundles the CLI it drives +— nothing to install. Both credential paths work; `providers.qwen.authType` +picks one, and omitting it auto-detects (a `~/.qwen` login wins over the key). + +**Subscription** — run `qwen` once to log in with a qwen.ai account. Tokens +live in `~/.qwen/oauth_creds.json` and never transit codeoid. + +**API key** — set `OPENAI_API_KEY` in `~/.codeoid/.env` and point +`providers.qwen.baseUrl` at the right gateway: + +| Preset | Use for | +| --- | --- | +| `dashscope-intl` | Model Studio pay-as-you-go keys (international) | +| `dashscope-cn` | Model Studio pay-as-you-go keys (China) | +| `bailian-plan-intl` | **Plan-specific keys (`sk-sp-…`)** from the Bailian token-plan | + +The last one matters: a `sk-sp-` plan key is rejected by the standard DashScope +hosts with `invalid_api_key`, which is a confusing failure to debug. Plan keys +also expose a broader catalog than Qwen alone (`qwen3.8-max`, `glm-5.2`, +`deepseek-v4-pro`, …) — set `providers.qwen.model` to pick one. + +```json +{ + "providers": { + "qwen": { + "authType": "openai", + "baseUrl": "bailian-plan-intl", + "model": "qwen3.8-max" + } + } +} +``` + +codeoid forces `permissionMode: "default"` so every write routes through the +approval gate — qwen-code's own `auto` mode would silently auto-approve edits +and shell commands. + +**Known gap — read-only shell commands bypass codeoid's gate.** qwen-code's +`resolveDefaultPermission` runs `isShellCommandReadOnlyAST(command)` and +auto-allows anything it classifies read-only, without ever raising a +permission request. Measured on this backend: `rm -f …` gates, but +`echo hello`, `cat /etc/hostname`, and `cat ~/.codeoid-decoy-secret.txt` all +executed **ungated** — including reads outside the workdir. So on the qwen +backend an agent can read any file the daemon user can (`~/.codeoid/config.json`, +`~/.ssh/…`) without `canUseTool` firing. + +This is a property of the backend, not of codeoid's gate: codeoid cannot +intercept a tool call the CLI never asks about. The classifier layer is +documented in qwen-code as not overridable by permission mode, so the only +blunt mitigation available today is `excludeTools: ["run_shell_command"]`, +which removes shell capability entirely. `buildQwenEnv()` closes the adjacent +env-var vector, but NOT this one. + +Audited against `gemini-cli` 0.50.0 (qwen-code is a fork of it): the gap looks +**qwen-specific**. Its bundle has no `isShellCommandReadOnlyAST` / +`resolveDefaultPermission`, and its shell `shouldConfirmExecute` has no +content-based read-only bypass — the only auto-allow paths are the policy +engine (`getMessageBusDecision`), YOLO mode, and sandbox policy, none of which +codeoid enables. That check is STATIC only; the live ACP confirmation was +blocked on expired `~/.gemini` OAuth (re-run `gemini` interactively to redo it). + Onboarding: `codeoid login [key] [--zeroid ]` verifies the key via a token exchange and writes `apiKey` (+ `zeroidUrl` if `--zeroid` given) to the config file. The shipped default issuer is the Highflame SaaS, so a hosted user diff --git a/bun.lock b/bun.lock index aa435044..7cf8cd3c 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@highflame/codeoid-core": "^0.4.0", "@highflame/codeoid-protocol": "^0.4.0", "@highflame/sdk": "^0.3.18", + "@qwen-code/sdk": "0.1.8", "@xenova/transformers": "^2.17.2", "commander": "^13.0.0", "grammy": "^1.35.0", @@ -235,6 +236,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@qwen-code/sdk": ["@qwen-code/sdk@0.1.8", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "zod": "^3.25.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "qwen-serve-mcp": "dist/daemon-mcp/serve-bridge/bin.js" } }, "sha512-b75LytcbAX2Jqk1C4pe9oX4vMY0is+AvRTTcoo90d2FdlM0dT97AfOp8+JWwK9dJImZyB7TcJ3vFmA6irUZBWw=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], "@smithy/core": ["@smithy/core@3.29.2", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw=="], @@ -781,6 +784,8 @@ "@earendil-works/pi-ai/openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + "@qwen-code/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/package.json b/package.json index ce6a41a8..3a6f73ba 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@highflame/codeoid-core": "^0.4.0", "@highflame/codeoid-protocol": "^0.4.0", "@highflame/sdk": "^0.3.18", + "@qwen-code/sdk": "0.1.8", "@xenova/transformers": "^2.17.2", "commander": "^13.0.0", "grammy": "^1.35.0", diff --git a/src/config.ts b/src/config.ts index c5b67d69..f828cf64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -629,13 +629,62 @@ const ProvidersSchema = z command: z.string().default("gemini"), }) .default({ enabled: true, command: "gemini" }), + /** Alibaba Qwen Code, driven in-process via `@qwen-code/sdk`. */ + qwen: z + .object({ + enabled: z.boolean().default(true), + /** + * Credential path. `openai` = the OpenAI-compatible API-key path + * (`OPENAI_API_KEY` + `baseUrl`); `qwen-oauth` = a qwen.ai + * subscription already logged in under `~/.qwen`. Omit to + * auto-detect: OAuth creds on disk win, else the key. + */ + authType: z.enum(["openai", "qwen-oauth"]).optional(), + /** + * OpenAI-compatible gateway. Accepts a {@link QWEN_BASE_URL_PRESETS} + * name or a full URL. Omit to let the CLI use its own default (or + * `OPENAI_BASE_URL` from the environment). + */ + baseUrl: z.string().optional(), + /** Default model when the session doesn't pick one (e.g. `qwen3.8-max`). */ + model: z.string().optional(), + /** + * Override the CLI the SDK drives. Omit to use the CLI bundled inside + * `@qwen-code/sdk`, which is the version this provider was tested + * against — the same posture as the pinned gemini-cli dependency. + */ + command: z.string().optional(), + }) + .default({ enabled: true }), }) .default({ pi: { enabled: true, command: "pi" }, codex: { enabled: true, command: "codex" }, geminiCli: { enabled: true, command: "gemini" }, + qwen: { enabled: true }, }); +/** + * Named Qwen gateways, so an operator never has to discover these by hand. + * + * `bailian-plan-*` is the endpoint behind a Model Studio *plan-specific* key + * (`sk-sp-…`, issued by the Bailian token-plan installer). It is NOT the + * standard DashScope host — plan keys are rejected there with + * `invalid_api_key`, which is a confusing failure to debug from scratch. + */ +export const QWEN_BASE_URL_PRESETS: Record = { + "dashscope-intl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "dashscope-cn": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "bailian-plan-intl": + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", +}; + +/** Resolve a `providers.qwen.baseUrl` preset name (or pass a URL through). */ +export function resolveQwenBaseUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + return QWEN_BASE_URL_PRESETS[value] ?? value; +} + /** * A single MCP server in the canonical registry (see * docs/provider-mcp-registry-design.md). Declared once here; codeoid mounts it @@ -1030,6 +1079,13 @@ export interface CodeoidConfig { enabled: boolean; command: string; }; + qwen: { + enabled: boolean; + authType?: "openai" | "qwen-oauth"; + baseUrl?: string; + model?: string; + command?: string; + }; }; /** * Canonical MCP server registry — declared once, mounted on every backend diff --git a/src/daemon/providers/env.ts b/src/daemon/providers/env.ts index 775a8b03..2fa91777 100644 --- a/src/daemon/providers/env.ts +++ b/src/daemon/providers/env.ts @@ -142,3 +142,44 @@ export function buildGeminiCliEnv( base, ); } + +/** The qwen-code allowlist policy — shared by the builder and its tests. */ +const QWEN_ENV_POLICY: SubprocessEnvPolicy = { + // The CLI's own namespaces: OPENAI_* is its OpenAI-compatible auth path + // (key + base URL + model), QWEN_* its own config/runtime namespace, + // DASHSCOPE_* the Alibaba Model Studio key. Plus POSIX locale categories. + prefixes: ["OPENAI_", "QWEN_", "DASHSCOPE_", "LC_"], + suffixes: ["_API_KEY"], +}; + +/** + * Environment for the qwen-code CLI the `@qwen-code/sdk` spawns. + * + * qwen-code's subscription credential store is `~/.qwen/oauth_creds.json` + * (HOME is in the shared basics — qwen.ai OAuth never transits codeoid), + * with env fallbacks for the API-key path (`OPENAI_API_KEY` + + * `OPENAI_BASE_URL` + `OPENAI_MODEL`, or `DASHSCOPE_API_KEY`). + * + * SECURITY — why this returns MORE than an allowlist. Unlike the Claude + * Agent SDK (which replaces the child env outright), `@qwen-code/sdk` spawns + * with `{ ...process.env, ...options.env }`. A plain allowlist would + * therefore be a no-op: everything we left out is still inherited, including + * the secrets `loadDotEnv` puts in the daemon's env (`CODEOID_API_KEY` = the + * root ZeroID key, `TELEGRAM_BOT_TOKEN`, provider keys). Since a merge can + * only add or override keys — never delete them — we explicitly map every + * non-allowlisted name in `base` to the empty string. After the SDK's merge + * the child sees exactly the allowlist, and every daemon secret reads as + * empty rather than leaking to the agent's Bash tool or stdio MCP servers. + * + * Pure + exported for unit testing. + */ +export function buildQwenEnv( + base: Record = process.env, +): Record { + const allowed = buildSubprocessEnv(QWEN_ENV_POLICY, base); + const out: Record = { ...allowed }; + for (const name of Object.keys(base)) { + if (!(name in allowed)) out[name] = ""; + } + return out; +} diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts new file mode 100644 index 00000000..2bdcd0f9 --- /dev/null +++ b/src/daemon/providers/qwen/index.ts @@ -0,0 +1,830 @@ +/** + * QwenProvider — Alibaba's Qwen Code as a codeoid backend, driven in-process + * through `@qwen-code/sdk`. + * + * Shape follows ClaudeProvider, not the ACP providers: the Qwen SDK is a + * deliberate clone of the Claude Agent SDK (`query({prompt, options})`, + * `createSdkMcpServer()`, `tool()`), so the warm-loop model — one long-running + * query per codeoid session, fed by an AsyncQueue of user messages — maps + * across directly. qwen-code also speaks ACP (`qwen --acp`), but that path has + * no in-process MCP mount and no typed auth selection, so the SDK wins. + * + * Auth (both paths, selected by `authType`): + * - `qwen-oauth` — a qwen.ai subscription already logged in under `~/.qwen`. + * Tokens never transit codeoid; HOME is in the subprocess env basics. + * - `openai` — the OpenAI-compatible key path: `OPENAI_API_KEY` plus a + * base URL. Note that a Model Studio *plan* key (`sk-sp-…`) is rejected by + * the standard DashScope host and needs the `bailian-plan-intl` gateway — + * see QWEN_BASE_URL_PRESETS. + * Omit `authType` and we auto-detect: OAuth creds on disk win, else the key. + * + * Two deliberate divergences from ClaudeProvider, both forced by the SDK: + * + * 1. Tool correlation. Qwen's `canUseTool` receives only `{signal, + * suggestions}` — there is no `toolUseID` like the Claude SDK passes, so + * the id has to come from the assistant message's `tool_use` block. See + * #matchPendingTool. + * 2. Subprocess env. The SDK spawns with `{...process.env, ...options.env}` + * (a MERGE, where the Claude SDK replaces), so an allowlist alone leaks + * the daemon's secrets. buildQwenEnv() blanks the rest — see its docs. + */ + +import { query, type Query, type SDKMessage, type SDKUserMessage } from "@qwen-code/sdk"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AsyncQueue } from "../../async-queue.js"; +import type { Store } from "../../store.js"; +import { buildMemoryMcpServer, MEMORY_TOOL_NAMES, type MemoryEngine } from "../../memory/index.js"; +import type { McpRegistry } from "../../mcp/registry.js"; +import { resolveEnvMap } from "../../mcp/types.js"; +import { FLEET_TOOL_NAMES } from "../../fleet.js"; +import { resolveQwenBaseUrl, type CodeoidConfig } from "../../../config.js"; +import type { AuthContext } from "../../../protocol/types.js"; +import type { + ModelInfo, + NormalizedTurnResult, + ProviderEvent, + SessionProvider, + TurnOpts, + TurnRun, +} from "../interface.js"; +import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from "../canonical.js"; +import { buildQwenEnv } from "../env.js"; +import type { LLMCallUsage } from "../../context-math.js"; + +/** Where qwen-code persists a qwen.ai subscription login. */ +const QWEN_OAUTH_CREDS = join(homedir(), ".qwen", "oauth_creds.json"); + +/** A tool call the model asked for, awaiting its approval + result. */ +interface PendingTool { + id: string; + name: string; + input: Record; + approvalId: string; + /** True once the approval gate has consumed this entry. */ + gated: boolean; +} + +export interface QwenProviderInit { + sessionId: string; + /** Persisted backing id from Store, or the session id itself on first run. */ + initialBackingId: string; + /** Tenant-scoped memory workspace id (computed once by Session). */ + workspaceId: string; + store: Store; + memory?: MemoryEngine; + /** codeoid_fleet MCP server — conductor sessions only. */ + fleet?: { type: "sdk"; name: string; instance: unknown }; + /** Cross-backend MCP registry — mounted natively (qwen owns its MCP client). */ + mcpRegistry?: McpRegistry; + config?: CodeoidConfig; + onModels?: ( + models: ReadonlyArray<{ value: string; displayName: string; description?: string }>, + ) => void; +} + +export class QwenProvider implements SessionProvider { + readonly id = "qwen"; + readonly displayName = "Qwen Code (Alibaba)"; + + onRecoveryNeeded: ((content: string) => void) | undefined; + + #backingId: string; + #hasQueried = false; + #init: QwenProviderInit; + + #query: Query | null = null; + #abortController: AbortController | null = null; + #inputQueue: AsyncQueue | null = null; + #consumerTask: Promise | null = null; + #currentTurnQueue: AsyncQueue | null = null; + #builtSystemPromptAppend = ""; + /** Bumped on every (re)build so an orphaned consumer can't clobber the new loop. */ + #loopGeneration = 0; + + #currentCanUseTool: TurnOpts["canUseTool"] | null = null; + #currentSender: AuthContext | null = null; + #pendingHistorySeed: string | null = null; + + /** In-flight tool calls, oldest first — see #matchPendingTool. */ + #pendingTools: PendingTool[] = []; + /** Subagent ids seen this session, so subagent_start fires exactly once. */ + #seenSubagents = new Set(); + /** Model the live loop was built with — the turn_done attribution fallback. */ + #currentModel: string | null = null; + + constructor(init: QwenProviderInit) { + this.#backingId = coerceBackingId(init.initialBackingId, init.sessionId); + this.#init = init; + } + + // ── Public accessors ────────────────────────────────────────────────────── + + get backingSessionId(): string { + return this.#backingId; + } + get queuedMessages(): number { + return this.#inputQueue?.size ?? 0; + } + get hasQueried(): boolean { + return this.#hasQueried; + } + /** Memory recall tools ride the in-process MCP mount whenever memory is wired. */ + get supportsMemoryTools(): boolean { + return this.#init.memory != null; + } + + setHasQueried(value: boolean): void { + this.#hasQueried = value; + } + + resetToNewSession(newBackingId: string): void { + this.#backingId = coerceBackingId(newBackingId, this.#init.sessionId); + this.#hasQueried = false; + this.#pendingTools = []; + this.#seenSubagents.clear(); + } + + // ── AgentProvider ───────────────────────────────────────────────────────── + + runTurn(opts: TurnOpts): TurnRun { + this.#currentCanUseTool = opts.canUseTool; + this.#currentSender = opts.sender ?? null; + + this.#ensureQueryLoop(opts); + + this.#currentTurnQueue?.close(); + const turnQueue = new AsyncQueue(); + this.#currentTurnQueue = turnQueue; + + let userMessage = opts.userMessage; + if (this.#pendingHistorySeed && userMessage) { + userMessage = `${this.#pendingHistorySeed}\n\n${userMessage}`; + this.#pendingHistorySeed = null; + } + if (userMessage) this.#push(userMessage); + + return { + events: turnQueue, + interrupt: async () => { + const q = this.#query; + if (q) { + try { + await q.interrupt(); + return; + } catch { + // fall through to hard abort + } + } + this.#abortController?.abort(); + this.#inputQueue?.close(); + }, + // The Qwen SDK's SDKUserMessage has no priority/shouldQuery fields, so a + // mid-turn push is just another queued user message. + pushMidTurn: (content) => this.#push(content), + }; + } + + seedFromHistory(history: readonly CanonicalTurn[], opts?: { maxChars?: number }): HistorySeedResult { + const seed = renderHistorySeed(history, { maxChars: opts?.maxChars }); + this.#pendingHistorySeed = seed.text.length > 0 ? seed.text : null; + return seed; + } + + seedText(block: string): void { + this.#pendingHistorySeed = block.length > 0 ? block : null; + } + + async listModels(): Promise { + if (!this.#query) return []; + try { + const raw = await this.#query.getAvailableModels(); + return normalizeModelCatalog(raw); + } catch { + return []; + } + } + + async dispose(): Promise { + await this.teardown(); + } + + async teardown(): Promise { + this.#inputQueue?.close(); + this.#abortController?.abort(); + if (this.#query) { + try { + await this.#query.close(); + } catch { + /* already closed */ + } + } + if (this.#consumerTask) { + try { + await this.#consumerTask; + } catch { + /* consumer handles its own errors */ + } + } + this.#currentTurnQueue?.close(); + this.#currentTurnQueue = null; + this.#inputQueue = null; + this.#consumerTask = null; + this.#query = null; + this.#abortController = null; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + #push(content: string): void { + if (!this.#inputQueue) { + console.error( + `[qwen-provider ${this.#init.sessionId.slice(0, 8)}] push without active queue — dropping`, + ); + return; + } + try { + this.#inputQueue.push({ + type: "user", + message: { role: "user", content }, + parent_tool_use_id: null, + session_id: this.#backingId, + } as SDKUserMessage); + } catch (err) { + console.error( + `[qwen-provider] push failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + #emit(event: ProviderEvent): void { + try { + this.#currentTurnQueue?.push(event); + } catch { + // Queue may be closed if the turn ended early — ignore. + } + } + + #ensureQueryLoop(opts: TurnOpts): void { + const desiredAppend = opts.systemPromptAppend ?? ""; + if (this.#consumerTask && this.#inputQueue && !this.#inputQueue.closed) { + // The SDK fixes the system prompt at query construction, so a changed + // append (before_turn hooks, refreshed memory index) needs a rebuild. + // The fresh query RESUMES the same backing session, so nothing is lost. + if (this.#builtSystemPromptAppend === desiredAppend) return; + console.log( + `[qwen-provider ${this.#init.sessionId.slice(0, 8)}] systemPromptAppend changed — rebuilding query loop`, + ); + this.#inputQueue.close(); + this.#abortController?.abort(); + } + + const init = this.#init; + const qwenCfg = init.config?.providers?.qwen; + this.#abortController = new AbortController(); + this.#inputQueue = new AsyncQueue(); + this.#builtSystemPromptAppend = desiredAppend; + this.#loopGeneration += 1; + const myGeneration = this.#loopGeneration; + + const sessionOpts = this.#hasQueried + ? { resume: this.#backingId } + : { sessionId: this.#backingId }; + + // In-process servers (memory, fleet) mount natively; the Claude SDK's + // McpSdkServerConfigWithInstance and Qwen's SDKMcpServerConfig are the same + // `{type:"sdk", name, instance}` shape, so the builder is reused as-is. + const mcpServers: Record = { + ...registryServersForQwen(init.mcpRegistry), + ...(init.memory + ? { + codeoid_memory: buildMemoryMcpServer(init.memory, { + workspaceId: init.workspaceId, + sessionId: init.sessionId, + }), + } + : {}), + ...(init.fleet ? { codeoid_fleet: init.fleet } : {}), + }; + + const authType = resolveQwenAuthType(qwenCfg?.authType); + const baseUrl = resolveQwenBaseUrl(qwenCfg?.baseUrl); + const model = opts.model ?? qwenCfg?.model; + this.#currentModel = model ?? null; + + this.#query = query({ + prompt: this.#inputQueue, + options: { + cwd: opts.workdir, + abortController: this.#abortController, + // Explicit allowlist + blanking of everything else — the SDK MERGES + // this over process.env rather than replacing it (see buildQwenEnv). + env: { + ...buildQwenEnv(), + ...(baseUrl ? { OPENAI_BASE_URL: baseUrl } : {}), + ...(model ? { OPENAI_MODEL: model } : {}), + }, + authType, + ...(model ? { model } : {}), + // qwen-code takes a LIST of capacity fallbacks (max 3), where the + // Claude SDK takes a single id; codeoid tracks one, so wrap it. + ...(opts.fallbackModel ? { fallbackModel: [opts.fallbackModel] } : {}), + ...(qwenCfg?.command ? { pathToQwenExecutable: qwenCfg.command } : {}), + // codeoid's gate is the only approval authority. `default` keeps + // read-only tools uncontrolled and routes every write through + // canUseTool; qwen-code's own `auto` mode would silently auto-approve + // edits and shell commands, bypassing the gate entirely. + permissionMode: "default", + includePartialMessages: true, + allowedTools: [ + ...(init.memory ? MEMORY_TOOL_NAMES.map((t) => `mcp__codeoid_memory__${t}`) : []), + ...(init.fleet ? FLEET_TOOL_NAMES.map((t) => `mcp__codeoid_fleet__${t}`) : []), + ], + ...(Object.keys(mcpServers).length > 0 + ? { mcpServers: mcpServers as never } + : {}), + ...(desiredAppend + ? { + systemPrompt: { + type: "preset" as const, + preset: "qwen_code" as const, + append: desiredAppend, + }, + } + : {}), + ...sessionOpts, + stderr: (data: string) => { + process.stderr.write(`[qwen-subprocess ${init.sessionId.slice(0, 8)}] ${data}`); + }, + canUseTool: async (toolName, input) => { + const inputObj = (input ?? {}) as Record; + const canUse = this.#currentCanUseTool; + if (!canUse) return { behavior: "deny" as const, message: "provider not ready" }; + + const pending = this.#matchPendingTool(toolName, inputObj); + init.store.audit( + this.#currentSender?.sub ?? "unknown", + "session.tool_call", + init.sessionId, + `tool=${toolName}`, + ); + const result = await canUse(pending.id, pending.approvalId, toolName, inputObj); + if (result.behavior === "allow") { + return { + behavior: "allow" as const, + updatedInput: (result.updatedInput ?? inputObj) as never, + }; + } + return { behavior: "deny" as const, message: result.message ?? "Denied" }; + }, + }, + }); + + this.#hasQueried = true; + + if (init.onModels) { + void this.#query + .getAvailableModels() + .then((raw) => { + const models = normalizeModelCatalog(raw); + if (models.length > 0) { + init.onModels?.( + models.map((m) => ({ + value: m.id, + displayName: m.displayName, + ...(m.description ? { description: m.description } : {}), + })), + ); + } + }) + .catch(() => {}); + } + + const query$ = this.#query; + const ac = this.#abortController; + const queue$ = this.#inputQueue; + let selfTask: Promise | null = null; + + selfTask = this.#consumerTask = (async () => { + try { + for await (const msg of query$) { + if (this.#loopGeneration !== myGeneration) break; + this.#translate(msg); + } + } catch (err) { + if (!ac.signal.aborted && this.#loopGeneration === myGeneration) { + const emsg = err instanceof Error ? err.message : String(err); + console.error(`[qwen-provider ${init.sessionId.slice(0, 8)}] SDK query failed: ${emsg}`); + this.#emit({ type: "error", message: emsg }); + } + } finally { + if (this.#loopGeneration === myGeneration) { + this.#currentTurnQueue?.close(); + this.#currentTurnQueue = null; + } + if (this.#query === query$) this.#query = null; + if (this.#abortController === ac) this.#abortController = null; + queue$?.close(); + if (this.#inputQueue === queue$) this.#inputQueue = null; + if (this.#consumerTask === selfTask) this.#consumerTask = null; + } + })(); + } + + /** + * Resolve the `tool_use` id for a `canUseTool` callback. + * + * Qwen's CanUseTool signature is `(toolName, input, {signal, suggestions})` — + * no `toolUseID`, unlike the Claude SDK (which codeoid leans on for issue + * #81). The real id therefore has to come from the assistant message's + * `tool_use` block, which always arrives BEFORE the permission request. + * + * Matching is oldest-first among UNGATED entries of the same NAME, never + * positional: read-only tools are auto-approved and never reach this + * callback, so a positional queue would desync on the first auto-allowed + * call — exactly the #81 failure. Name-keyed is sound because auto-approval + * is a property of the tool's identity, so either every call of a name is + * gated or none is. + * + * A miss (no announced block — e.g. a tool synthesised outside the assistant + * turn) still yields a usable correlation pair rather than denying, and is + * announced so the client renders it. + */ + #matchPendingTool(toolName: string, input: Record): PendingTool { + const hit = this.#pendingTools.find((p) => p.name === toolName && !p.gated); + if (hit) { + hit.gated = true; + return hit; + } + const synthetic: PendingTool = { + id: randomUUID(), + name: toolName, + input, + approvalId: randomUUID(), + gated: true, + }; + this.#pendingTools.push(synthetic); + this.#emit({ + type: "tool_start", + toolId: synthetic.id, + sdkToolUseId: synthetic.id, + name: toolName, + input, + approvalId: synthetic.approvalId, + }); + return synthetic; + } + + /** Translate one SDKMessage into ProviderEvents. */ + #translate(msg: SDKMessage): void { + translateQwenMessage(msg, this.#emit.bind(this), this.id, { + pendingTools: this.#pendingTools, + seenSubagents: this.#seenSubagents, + requestedModel: this.#currentModel, + }); + } +} + +// ── Pure translation (exported for unit tests) ──────────────────────────────── + +export interface QwenTranslateState { + pendingTools: PendingTool[]; + seenSubagents: Set; + /** + * Model this loop was built with. qwen-code's `result` message frequently + * omits `modelUsage` (observed empty against the Bailian gateway), which + * would leave every turn attributed to "unknown" in usage accounting — so + * the requested id is the fallback. + */ + requestedModel?: string | null; +} + +/** + * Translate one `@qwen-code/sdk` SDKMessage into zero or more ProviderEvents. + * Pure apart from calling `emit` and mutating the caller-owned `state`, so it + * can be unit-tested without spawning the CLI. + */ +export function translateQwenMessage( + msg: SDKMessage, + emit: (event: ProviderEvent) => void, + providerId: string, + state: QwenTranslateState, +): void { + switch (msg.type) { + case "assistant": { + const m = msg as unknown as { + message: { + content?: unknown; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + parent_tool_use_id: string | null; + }; + const parentToolUseId = m.parent_tool_use_id ?? null; + + // The SDK exposes no SubagentStart hook, so a first sighting of a + // parent_tool_use_id IS the subagent's start. agent_type isn't reported; + // the spawning tool's name is the closest available label. + if (parentToolUseId && !state.seenSubagents.has(parentToolUseId)) { + state.seenSubagents.add(parentToolUseId); + const spawner = state.pendingTools.find((p) => p.id === parentToolUseId); + emit({ + type: "subagent_start", + agentId: parentToolUseId, + agentType: spawner?.name ?? "subagent", + }); + } + + const perCall = m.message?.usage; + if (perCall) { + const usage: LLMCallUsage = { + inputTokens: perCall.input_tokens ?? 0, + cacheReadTokens: perCall.cache_read_input_tokens ?? 0, + cacheCreationTokens: perCall.cache_creation_input_tokens ?? 0, + outputTokens: perCall.output_tokens ?? 0, + }; + emit({ type: "llm_call", usage, isPrimary: parentToolUseId === null }); + } + + const content = Array.isArray(m.message?.content) + ? (m.message.content as Array>) + : []; + const textParts: string[] = []; + for (const block of content) { + if (block.type === "text" && typeof block.text === "string") { + textParts.push(block.text); + continue; + } + // Announce tool calls here — this is the only place the real + // tool_use id is available (see QwenProvider#matchPendingTool). + if (block.type === "tool_use" && typeof block.id === "string") { + if (state.pendingTools.some((p) => p.id === block.id)) continue; + const pending: PendingTool = { + id: block.id, + name: typeof block.name === "string" ? block.name : "unknown", + input: (block.input as Record) ?? {}, + approvalId: randomUUID(), + gated: false, + }; + state.pendingTools.push(pending); + emit({ + type: "tool_start", + toolId: pending.id, + sdkToolUseId: pending.id, + ...(parentToolUseId ? { sdkAgentId: parentToolUseId } : {}), + name: pending.name, + input: pending.input, + approvalId: pending.approvalId, + }); + } + } + if (textParts.length > 0) { + emit({ type: "text_done", content: textParts.join(""), parentToolUseId }); + } + break; + } + + case "stream_event": { + const m = msg as unknown as { + event?: { + type?: string; + index?: number; + content_block?: { type?: string }; + delta?: { type?: string; text?: string; thinking?: string }; + }; + parent_tool_use_id?: string | null; + }; + const event = m.event; + if (!event) break; + const parentToolUseId = m.parent_tool_use_id ?? null; + + if (event.type === "content_block_start" && event.content_block?.type === "thinking") { + emit({ type: "thinking_delta", content: "", blockIndex: event.index, parentToolUseId }); + break; + } + if (event.type === "content_block_delta" && event.delta) { + if (event.delta.type === "text_delta" && event.delta.text) { + emit({ type: "text_delta", content: event.delta.text, parentToolUseId }); + } else if (event.delta.type === "thinking_delta" && event.delta.thinking) { + emit({ + type: "thinking_delta", + content: event.delta.thinking, + blockIndex: event.index, + parentToolUseId, + }); + } + break; + } + if (event.type === "content_block_stop") { + emit({ type: "thinking_done", blockIndex: event.index, parentToolUseId }); + } + break; + } + + case "user": { + const content = (msg.message as { content?: unknown }).content; + if (!Array.isArray(content)) break; + for (const block of content as Array>) { + if (block.type !== "tool_result") continue; + const useId = typeof block.tool_use_id === "string" ? block.tool_use_id : null; + if (!useId) continue; + emit({ + type: "tool_complete", + sdkToolUseId: useId, + output: extractToolResultText(block.content), + success: block.is_error !== true, + }); + const i = state.pendingTools.findIndex((p) => p.id === useId); + if (i !== -1) state.pendingTools.splice(i, 1); + } + break; + } + + case "system": { + const subtype = (msg as { subtype?: string }).subtype; + if (subtype !== "init") break; + const m = msg as { mcp_servers?: Array<{ name: string; status: string }>; tools?: string[] }; + const servers: Record = {}; + const tools: Record = {}; + for (const s of m.mcp_servers ?? []) { + servers[s.name] = s.status; + tools[s.name] = []; + } + for (const t of m.tools ?? []) { + if (!t.startsWith("mcp__")) continue; + const rest = t.slice("mcp__".length); + const sep = rest.indexOf("__"); + if (sep <= 0) continue; + const server = rest.slice(0, sep); + tools[server] ??= []; + tools[server].push(t); + } + emit({ type: "mcp_init", servers, tools }); + break; + } + + case "result": { + const r = msg as unknown as { + subtype?: string; + is_error?: boolean; + num_turns?: number; + result?: string; + duration_ms?: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + modelUsage?: Record; + error?: { message?: string }; + }; + // A turn is over: nothing may stay pending into the next one, or a stale + // entry would mis-correlate a later same-named call. + state.pendingTools.length = 0; + state.seenSubagents.clear(); + + const normalized: NormalizedTurnResult = { + providerId, + model: Object.keys(r.modelUsage ?? {})[0] ?? state.requestedModel ?? "unknown", + inputTokens: r.usage?.input_tokens ?? 0, + outputTokens: r.usage?.output_tokens ?? 0, + cacheReadTokens: r.usage?.cache_read_input_tokens ?? 0, + cacheCreationTokens: r.usage?.cache_creation_input_tokens ?? 0, + // The Qwen gateway does not price responses; cost stays 0 like the + // other non-Anthropic backends. + totalCostUsd: 0, + durationMs: r.duration_ms ?? 0, + stopReason: r.subtype, + isError: r.is_error, + errorMessage: + r.is_error === true ? (r.error?.message ?? r.result ?? "qwen turn failed") : undefined, + }; + emit({ type: "turn_done", result: normalized }); + break; + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Guarantee the backing id the SDK receives is a UUID. + * + * `query()` validates `sessionId`/`resume` and THROWS SYNCHRONOUSLY on a + * non-UUID ("Invalid sessionId … Must be a valid UUID"). That throw would + * escape `runTurn()` rather than arriving as a turn error, wedging the + * session. Live codeoid ids are `randomUUID()`, so this only fires for a + * backing id persisted by another backend, a hand-built id, or a test — where + * degrading to a fresh backing session beats a hard failure. + * + * Pure + exported for unit testing. + */ +export function coerceBackingId(candidate: string, sessionId: string): string { + if (UUID_RE.test(candidate)) return candidate; + const replacement = randomUUID(); + console.warn( + `[qwen-provider ${sessionId.slice(0, 8)}] backing id ${JSON.stringify(candidate)} is not a UUID — qwen-code requires one; starting a fresh backing session instead`, + ); + return replacement; +} + +/** + * Pick the credential path. An explicit config value always wins; otherwise a + * qwen.ai login on disk beats the API key, matching the subscription-first + * posture codeoid uses for claude/codex. + */ +export function resolveQwenAuthType( + configured: "openai" | "qwen-oauth" | undefined, + oauthCredsPath: string = QWEN_OAUTH_CREDS, +): "openai" | "qwen-oauth" { + if (configured) return configured; + return existsSync(oauthCredsPath) ? "qwen-oauth" : "openai"; +} + +/** + * Normalize `Query.getAvailableModels()`, which is typed only as + * `Record | null`. Accepts the observed + * `{ availableModels: [{ modelId, name, description }] }` shape plus a bare + * array, and ignores anything else rather than throwing. + */ +export function normalizeModelCatalog(raw: unknown): ModelInfo[] { + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as { availableModels?: unknown })?.availableModels) + ? ((raw as { availableModels: unknown[] }).availableModels) + : Array.isArray((raw as { models?: unknown })?.models) + ? ((raw as { models: unknown[] }).models) + : []; + const out: ModelInfo[] = []; + for (const entry of list) { + if (typeof entry === "string") { + out.push({ id: entry, displayName: entry }); + continue; + } + if (typeof entry !== "object" || entry === null) continue; + const e = entry as Record; + const id = typeof e.modelId === "string" ? e.modelId : typeof e.id === "string" ? e.id : null; + if (!id) continue; + out.push({ + id, + displayName: typeof e.name === "string" ? e.name : id, + ...(typeof e.description === "string" ? { description: e.description } : {}), + }); + } + return out; +} + +/** + * Registry servers for the qwen backend, as SDK MCP configs — a native mount, + * since qwen-code owns its own MCP client. `${VAR}` env refs and + * `bearerTokenEnv` resolve against the daemon env here so secrets never live + * in config; tool calls still gate through canUseTool. + */ +export function registryServersForQwen( + registry: McpRegistry | undefined, +): Record { + if (!registry) return {}; + const out: Record = {}; + for (const spec of registry.forBackend("qwen")) { + if (spec.builtin) continue; + const t = spec.transport; + if (t.kind === "stdio") { + out[spec.name] = { + command: t.command, + args: t.args, + env: resolveEnvMap(t.env ?? {}, process.env), + }; + } else if (t.kind === "http") { + const headers: Record = { ...t.headers }; + if (t.bearerTokenEnv) { + const tok = process.env[t.bearerTokenEnv]; + if (tok) headers.Authorization = `Bearer ${tok}`; + } + out[spec.name] = { httpUrl: t.url, headers }; + } + } + return out; +} + +export function extractToolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content as Array>) { + if (block.type === "image") { + parts.push("[image]"); + } else if (typeof block.text === "string") { + parts.push(block.text); + } + } + return parts.join("\n"); +} diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index 6c8760e3..187d9028 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -31,6 +31,7 @@ import { CodexProvider } from "./codex/index.js"; import { CODEX_INSTALL_HINT, resolveCodexCommand } from "./codex/resolve.js"; import { GeminiAcpProvider } from "./acp/index.js"; import { GEMINI_CLI_INSTALL_HINT, resolveGeminiCliCommand } from "./acp/resolve.js"; +import { QwenProvider } from "./qwen/index.js"; import { StatelessSessionProvider } from "./stateless.js"; /** @@ -330,5 +331,28 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR ); } } + if (config?.providers?.qwen?.enabled !== false) { + // No binary probe: @qwen-code/sdk bundles the CLI it drives, so the backend + // is always activatable (same reasoning as the bundled gemini-cli, minus the + // PATH lookup). Auth is checked at first turn, not at registration — + // `qwen-oauth` credentials and `OPENAI_API_KEY` are both late-bound, and a + // subscription user has neither set in the daemon env. + registry.register({ + id: "qwen", + displayName: "Qwen Code (Alibaba)", + create: (init) => + new QwenProvider({ + sessionId: init.sessionId, + initialBackingId: init.initialBackingId, + workspaceId: init.workspaceId, + store: init.store, + memory: init.memory, + fleet: init.fleet as { type: "sdk"; name: string; instance: unknown } | undefined, + mcpRegistry: init.mcpRegistry, + config: init.config, + onModels: init.onModels, + }), + }); + } return registry; } diff --git a/src/tests/provider-qwen.test.ts b/src/tests/provider-qwen.test.ts new file mode 100644 index 00000000..4622bba0 --- /dev/null +++ b/src/tests/provider-qwen.test.ts @@ -0,0 +1,411 @@ +import { describe, test, expect } from "bun:test"; +import { + translateQwenMessage, + resolveQwenAuthType, + normalizeModelCatalog, + extractToolResultText, + coerceBackingId, + type QwenTranslateState, +} from "../daemon/providers/qwen/index.js"; +import { buildQwenEnv } from "../daemon/providers/env.js"; +import { resolveQwenBaseUrl, QWEN_BASE_URL_PRESETS } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; + +function collector(): { events: ProviderEvent[]; emit: (e: ProviderEvent) => void } { + const events: ProviderEvent[] = []; + return { events, emit: (e) => events.push(e) }; +} + +function freshState(): QwenTranslateState { + return { pendingTools: [], seenSubagents: new Set() }; +} + +describe("buildQwenEnv (GHSA-38vh vector 3, merge semantics)", () => { + // @qwen-code/sdk spawns with {...process.env, ...options.env}, so an + // allowlist alone leaks everything it omits. Blanking is the actual control. + const base = { + PATH: "/usr/bin", + HOME: "/home/u", + OPENAI_API_KEY: "sk-real", + OPENAI_BASE_URL: "https://gateway/v1", + QWEN_HOME: "/home/u/.qwen", + DASHSCOPE_API_KEY: "dash", + CODEOID_API_KEY: "zid_sk_ROOT", + TELEGRAM_BOT_TOKEN: "bot-secret", + ZEROID_URL: "highflame", + SOME_OTHER_SECRET: "nope", + }; + + test("passes through the qwen credential namespaces", () => { + const env = buildQwenEnv(base); + expect(env.OPENAI_API_KEY).toBe("sk-real"); + expect(env.OPENAI_BASE_URL).toBe("https://gateway/v1"); + expect(env.QWEN_HOME).toBe("/home/u/.qwen"); + expect(env.DASHSCOPE_API_KEY).toBe("dash"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.HOME).toBe("/home/u"); + }); + + test("BLANKS daemon secrets rather than merely omitting them", () => { + const env = buildQwenEnv(base); + // Present-but-empty is the point: an omitted key would survive the merge. + for (const leaky of ["CODEOID_API_KEY", "TELEGRAM_BOT_TOKEN", "ZEROID_URL", "SOME_OTHER_SECRET"]) { + expect(env[leaky]).toBe(""); + } + }); + + test("every base key is accounted for, so the merge cannot reintroduce one", () => { + const env = buildQwenEnv(base); + for (const k of Object.keys(base)) expect(k in env).toBe(true); + }); + + test("the root ZeroID key is denied even though it matches the _API_KEY suffix", () => { + expect(buildQwenEnv(base).CODEOID_API_KEY).toBe(""); + }); +}); + +describe("resolveQwenAuthType", () => { + test("explicit config always wins", () => { + expect(resolveQwenAuthType("openai", "/definitely/missing")).toBe("openai"); + expect(resolveQwenAuthType("qwen-oauth", "/definitely/missing")).toBe("qwen-oauth"); + }); + + test("auto-detect prefers a subscription login on disk, else the key path", () => { + expect(resolveQwenAuthType(undefined, "/definitely/missing")).toBe("openai"); + // Any file that exists stands in for ~/.qwen/oauth_creds.json. + expect(resolveQwenAuthType(undefined, import.meta.path)).toBe("qwen-oauth"); + }); +}); + +describe("coerceBackingId", () => { + // query() throws SYNCHRONOUSLY on a non-UUID sessionId, which would escape + // runTurn() and wedge the session rather than surfacing as a turn error. + test("passes a real UUID through untouched", () => { + const id = "11111111-2222-4333-8444-555555555555"; + expect(coerceBackingId(id, "sess")).toBe(id); + }); + + test("replaces a non-UUID backing id with a fresh UUID", () => { + for (const bad of ["sess-e2e", "", "not-a-uuid", "1234"]) { + const out = coerceBackingId(bad, "sess"); + expect(out).not.toBe(bad); + expect(out).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + } + }); +}); + +describe("resolveQwenBaseUrl", () => { + test("maps the plan-key gateway preset (a standard DashScope host 401s on sk-sp- keys)", () => { + expect(resolveQwenBaseUrl("bailian-plan-intl")).toBe( + QWEN_BASE_URL_PRESETS["bailian-plan-intl"], + ); + expect(resolveQwenBaseUrl("bailian-plan-intl")).toContain("token-plan"); + }); + + test("passes a full URL through and leaves undefined alone", () => { + expect(resolveQwenBaseUrl("https://custom/v1")).toBe("https://custom/v1"); + expect(resolveQwenBaseUrl(undefined)).toBeUndefined(); + }); +}); + +describe("translateQwenMessage — tool announcement + correlation", () => { + test("a tool_use block announces tool_start carrying the REAL sdk id", () => { + const { events, emit } = collector(); + const state = freshState(); + translateQwenMessage( + { + type: "assistant", + parent_tool_use_id: null, + message: { + content: [ + { type: "tool_use", id: "call_abc", name: "write_file", input: { path: "a.txt" } }, + ], + }, + } as never, + emit, + "qwen", + state, + ); + const start = events.find((e) => e.type === "tool_start"); + expect(start).toBeDefined(); + expect(start).toMatchObject({ sdkToolUseId: "call_abc", name: "write_file" }); + expect(state.pendingTools).toHaveLength(1); + expect(state.pendingTools[0].gated).toBe(false); + }); + + test("a repeated tool_use id is not announced twice", () => { + const { events, emit } = collector(); + const state = freshState(); + const msg = { + type: "assistant", + parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id: "call_abc", name: "write_file", input: {} }] }, + } as never; + translateQwenMessage(msg, emit, "qwen", state); + translateQwenMessage(msg, emit, "qwen", state); + expect(events.filter((e) => e.type === "tool_start")).toHaveLength(1); + }); + + test("tool_result closes the call and drops it from the pending set", () => { + const { events, emit } = collector(); + const state = freshState(); + translateQwenMessage( + { + type: "assistant", + parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id: "call_abc", name: "write_file", input: {} }] }, + } as never, + emit, + "qwen", + state, + ); + translateQwenMessage( + { + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "call_abc", content: "wrote it" }], + }, + } as never, + emit, + "qwen", + state, + ); + expect(events.at(-1)).toMatchObject({ + type: "tool_complete", + sdkToolUseId: "call_abc", + output: "wrote it", + success: true, + }); + expect(state.pendingTools).toHaveLength(0); + }); + + test("a failed tool_result reports success:false", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: "x", content: "boom", is_error: true }, + ], + }, + } as never, + emit, + "qwen", + freshState(), + ); + expect(events.at(-1)).toMatchObject({ type: "tool_complete", success: false }); + }); + + test("a result clears pending state so the next turn cannot mis-correlate", () => { + const { emit } = collector(); + const state = freshState(); + translateQwenMessage( + { + type: "assistant", + parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id: "stale", name: "write_file", input: {} }] }, + } as never, + emit, + "qwen", + state, + ); + expect(state.pendingTools).toHaveLength(1); + translateQwenMessage({ type: "result", subtype: "success", usage: {} } as never, emit, "qwen", state); + expect(state.pendingTools).toHaveLength(0); + }); +}); + +describe("translateQwenMessage — subagents (issue #82)", () => { + test("subagent text is tagged with parentToolUseId and never read as primary", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "assistant", + parent_tool_use_id: "call_parent", + message: { content: [{ type: "text", text: "from the subagent" }] }, + } as never, + emit, + "qwen", + freshState(), + ); + const text = events.find((e) => e.type === "text_done"); + expect(text).toMatchObject({ parentToolUseId: "call_parent" }); + }); + + test("a first sighting of a parent id emits subagent_start exactly once", () => { + const { events, emit } = collector(); + const state = freshState(); + const msg = { + type: "assistant", + parent_tool_use_id: "call_parent", + message: { content: [{ type: "text", text: "hi" }] }, + } as never; + translateQwenMessage(msg, emit, "qwen", state); + translateQwenMessage(msg, emit, "qwen", state); + expect(events.filter((e) => e.type === "subagent_start")).toHaveLength(1); + }); + + test("primary text carries a null parent", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "assistant", + parent_tool_use_id: null, + message: { content: [{ type: "text", text: "primary" }] }, + } as never, + emit, + "qwen", + freshState(), + ); + expect(events.find((e) => e.type === "text_done")).toMatchObject({ parentToolUseId: null }); + }); +}); + +describe("translateQwenMessage — streaming + result", () => { + test("text and thinking deltas map to their codeoid events", () => { + const { events, emit } = collector(); + const state = freshState(); + translateQwenMessage( + { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text: "hel" } }, + } as never, + emit, + "qwen", + state, + ); + translateQwenMessage( + { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "hmm" } }, + } as never, + emit, + "qwen", + state, + ); + expect(events[0]).toMatchObject({ type: "text_delta", content: "hel" }); + expect(events[1]).toMatchObject({ type: "thinking_delta", content: "hmm", blockIndex: 1 }); + }); + + test("result maps usage and marks errors", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1200, + usage: { input_tokens: 10, output_tokens: 4, cache_read_input_tokens: 7 }, + modelUsage: { "qwen3.8-max": {} }, + } as never, + emit, + "qwen", + freshState(), + ); + expect(events.at(-1)).toMatchObject({ + type: "turn_done", + result: { + providerId: "qwen", + model: "qwen3.8-max", + inputTokens: 10, + outputTokens: 4, + cacheReadTokens: 7, + durationMs: 1200, + }, + }); + }); + + test("falls back to the requested model when the gateway omits modelUsage", () => { + // Observed against the Bailian plan gateway: result carries usage but no + // modelUsage, which would attribute every turn to "unknown". + const { events, emit } = collector(); + translateQwenMessage( + { type: "result", subtype: "success", usage: {} } as never, + emit, + "qwen", + { ...freshState(), requestedModel: "qwen3.8-max" }, + ); + expect((events.at(-1) as { result: { model: string } }).result.model).toBe("qwen3.8-max"); + }); + + test("modelUsage still wins when the gateway does report it", () => { + const { events, emit } = collector(); + translateQwenMessage( + { type: "result", subtype: "success", usage: {}, modelUsage: { "glm-5.2": {} } } as never, + emit, + "qwen", + { ...freshState(), requestedModel: "qwen3.8-max" }, + ); + expect((events.at(-1) as { result: { model: string } }).result.model).toBe("glm-5.2"); + }); + + test("an errored result surfaces its message", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "result", + subtype: "error_during_execution", + is_error: true, + usage: {}, + error: { message: "gateway exploded" }, + } as never, + emit, + "qwen", + freshState(), + ); + const done = events.at(-1) as Extract; + expect(done.result.isError).toBe(true); + expect(done.result.errorMessage).toBe("gateway exploded"); + }); + + test("system init reports mounted MCP servers and their tools", () => { + const { events, emit } = collector(); + translateQwenMessage( + { + type: "system", + subtype: "init", + mcp_servers: [{ name: "codeoid_memory", status: "connected" }], + tools: ["write_file", "mcp__codeoid_memory__recall"], + } as never, + emit, + "qwen", + freshState(), + ); + expect(events.at(-1)).toMatchObject({ + type: "mcp_init", + servers: { codeoid_memory: "connected" }, + tools: { codeoid_memory: ["mcp__codeoid_memory__recall"] }, + }); + }); +}); + +describe("normalizeModelCatalog", () => { + test("reads the observed availableModels shape", () => { + expect( + normalizeModelCatalog({ + availableModels: [ + { modelId: "qwen3.8-max", name: "Qwen3.8 Max", description: "flagship" }, + ], + }), + ).toEqual([{ id: "qwen3.8-max", displayName: "Qwen3.8 Max", description: "flagship" }]); + }); + + test("tolerates bare arrays, strings, and junk without throwing", () => { + expect(normalizeModelCatalog(["a"])).toEqual([{ id: "a", displayName: "a" }]); + expect(normalizeModelCatalog(null)).toEqual([]); + expect(normalizeModelCatalog({ nope: 1 })).toEqual([]); + expect(normalizeModelCatalog([{ noId: true }])).toEqual([]); + }); +}); + +describe("extractToolResultText", () => { + test("handles strings, block arrays, and images", () => { + expect(extractToolResultText("plain")).toBe("plain"); + expect(extractToolResultText([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toBe("a\nb"); + expect(extractToolResultText([{ type: "image" }])).toBe("[image]"); + expect(extractToolResultText(undefined)).toBe(""); + }); +});