diff --git a/apps/desktop/scripts/v2-electron-runtime-smoke.mjs b/apps/desktop/scripts/v2-electron-runtime-smoke.mjs index 3d5dea8c..d3ff7e5c 100644 --- a/apps/desktop/scripts/v2-electron-runtime-smoke.mjs +++ b/apps/desktop/scripts/v2-electron-runtime-smoke.mjs @@ -19,7 +19,7 @@ import { CONTRACT_VERSION } from "../src/shared/contracts/index.ts"; const require = createRequire(import.meta.url); const electronPath = require("electron"); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const selectableAgents = new Set(["codex", "claude", "opencode"]); +const selectableAgents = new Set(["codex", "claude", "opencode", "qwen"]); const options = parseArgs(process.argv.slice(2)); if (options.help) { @@ -260,7 +260,7 @@ function printHelp() { console.log(`Usage: npm run test:smoke:electron -- --agent codex Options: - --agent codex|claude|opencode + --agent codex|claude|opencode|qwen --timeout-ms 75000 --app-data-root /tmp/tide-electron-smoke --message "Prompt text" diff --git a/apps/desktop/scripts/v2-launch-change-flow.mjs b/apps/desktop/scripts/v2-launch-change-flow.mjs index 716cfa5f..16b0ea75 100644 --- a/apps/desktop/scripts/v2-launch-change-flow.mjs +++ b/apps/desktop/scripts/v2-launch-change-flow.mjs @@ -15,7 +15,7 @@ // matrix. One option per thread: a restart-required change sets // pendingRuntimeRestart, which would contaminate a second option's `applied`. // -// --behavioral (permission only; claude/opencode/codex) +// --behavioral (permission only; claude/opencode/codex/qwen) // Prove the change actually flips a RUNNING session's approval behavior: turn 1 // in a prompting mode MUST surface an approval prompt; flip permission to a // bypass mode mid-thread; turn 2 doing the same tool MUST NOT prompt and the @@ -23,7 +23,7 @@ // // Usage: // node --experimental-strip-types scripts/v2-launch-change-flow.mjs \ -// --agent [--option permission|model|reasoning] +// --agent [--option permission|model|reasoning] // [--behavioral] [--timeout-ms 120000] // // Run with TIDE_BACKEND_TRACE=1 to also see the `applySessionConfig keys=…` @@ -57,8 +57,8 @@ const behavioral = argv.includes("--behavioral"); const option = arg("--option", "permission"); const timeoutMs = Number(arg("--timeout-ms", process.env.TIDE_TIMEOUT_MS ?? 120000)); -if (!["claude", "codex", "opencode"].includes(agent)) { - console.error("Usage: --agent [--option permission|model|reasoning] [--behavioral]"); +if (!["claude", "codex", "opencode", "qwen"].includes(agent)) { + console.error("Usage: --agent [--option permission|model|reasoning] [--behavioral]"); process.exit(2); } @@ -68,23 +68,27 @@ const EXPECTED = { claude: { permission: "live", model: "live", reasoning: "next_turn" }, codex: { permission: "next_turn", model: "live", reasoning: "live" }, opencode: { permission: "live", model: "live", reasoning: "live" }, + qwen: { permission: "live", model: "live", reasoning: "live" }, }; // A baseline that differs from the target so the change registers as a changed key. const BASELINE_PERMISSION = { claude: "default", codex: "ask-for-approval", opencode: "build", + qwen: "default", }; const TARGET = { claude: { permission: "acceptEdits", model: "claude-sonnet-4-6", reasoning: "high" }, codex: { permission: "full-access", model: "gpt-5.1-codex-max", reasoning: "high" }, opencode: { permission: "plan", model: "anthropic/claude-sonnet-4-6", reasoning: "high" }, + qwen: { permission: "plan", model: "qwen3-coder-plus", reasoning: "high" }, }; // A bypass permission that auto-approves shell/edit tools, for the behavioral flip. const BYPASS_PERMISSION = { claude: "bypassPermissions", codex: "full-access", opencode: "build", + qwen: "yolo", }; const appDataRoot = mkdtempSync(path.join(tmpdir(), `tide-launch-change-${agent}-`)); diff --git a/apps/desktop/scripts/v2-provider-permission-flow.mjs b/apps/desktop/scripts/v2-provider-permission-flow.mjs index 7667f1f5..d2543960 100644 --- a/apps/desktop/scripts/v2-provider-permission-flow.mjs +++ b/apps/desktop/scripts/v2-provider-permission-flow.mjs @@ -7,7 +7,7 @@ // 2. the same prompt never re-surfaces after it was answered (no double prompt), // 3. the turn finally settles with an answer instead of hanging "Working". // -// Usage: node scripts/v2-provider-permission-flow.mjs --agent +// Usage: node scripts/v2-provider-permission-flow.mjs --agent // [--deny] [--timeout-ms 240000] import { mkdtempSync } from "node:fs"; @@ -55,11 +55,16 @@ const SCENARIOS = { message: "Run the shell command `touch /tmp/tide-perm-probe-opencode.txt` (it needs approval), then reply exactly DONE.", }, + qwen: { + permission: "default", + message: + "Run the shell command `touch /tmp/tide-perm-probe-qwen.txt` (it needs approval), then reply exactly DONE.", + }, }; const scenario = SCENARIOS[agent]; if (scenario === undefined) { - console.error("Usage: node scripts/v2-provider-permission-flow.mjs --agent "); + console.error("Usage: node scripts/v2-provider-permission-flow.mjs --agent "); process.exit(2); } const message = process.env.TIDE_MESSAGE ?? scenario.message; diff --git a/apps/desktop/scripts/v2-provider-smoke.mjs b/apps/desktop/scripts/v2-provider-smoke.mjs index 2506eb8c..bd7dbfcb 100644 --- a/apps/desktop/scripts/v2-provider-smoke.mjs +++ b/apps/desktop/scripts/v2-provider-smoke.mjs @@ -18,7 +18,7 @@ import { createLiveBackendContractMessageAdapter } from "../src/backend/infrastr import { CONTRACT_VERSION } from "../src/shared/contracts/index.ts"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const providerCliAgents = new Set(["codex", "claude", "opencode"]); +const providerCliAgents = new Set(["codex", "claude", "opencode", "qwen"]); const selectableAgents = providerCliAgents; const options = parseArgs(process.argv.slice(2)); @@ -285,6 +285,8 @@ function labelForAgent(agent) { return "Claude Code"; case "opencode": return "opencode"; + case "qwen": + return "Qwen Code"; default: return agent; } @@ -393,7 +395,7 @@ function printHelp() { console.log(`Usage: npm run test:smoke:providers -- --agent codex Options: - --agent codex|claude|opencode + --agent codex|claude|opencode|qwen --timeout-ms 75000 --app-data-root /tmp/tide-provider-smoke --message "Prompt text" diff --git a/apps/desktop/scripts/v2-provider-state-matrix.mjs b/apps/desktop/scripts/v2-provider-state-matrix.mjs index cc9a4eb2..669a010e 100644 --- a/apps/desktop/scripts/v2-provider-state-matrix.mjs +++ b/apps/desktop/scripts/v2-provider-state-matrix.mjs @@ -123,7 +123,7 @@ async function notinstalled() { // readiness blocker instead of spawning anything or hanging. process.env.PATH = "/var/empty"; const adapter = await makeAdapter(); - for (const agentId of ["claude", "codex", "opencode"]) { + for (const agentId of ["claude", "codex", "opencode", "qwen"]) { const events = await adapter.handleMessage( startThreadCommand(agentId, "hello?"), ); @@ -155,7 +155,7 @@ async function notauth() { // applicable) instead of spawning a CLI that dies on a login screen. const freshHome = mkdtempSync(path.join(tmpdir(), "tide-matrix-home-")); const adapter = await makeAdapter({ HOME: freshHome }); - for (const agentId of ["claude", "codex", "opencode"]) { + for (const agentId of ["claude", "codex", "opencode", "qwen"]) { const events = await adapter.handleMessage( startThreadCommand(agentId, "hello?"), ); diff --git a/apps/desktop/src/backend/adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts b/apps/desktop/src/backend/adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts new file mode 100644 index 00000000..44cdef06 --- /dev/null +++ b/apps/desktop/src/backend/adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts @@ -0,0 +1,218 @@ +import type { + AgentIntegrationCapabilities, + AgentIntegrationPort, + AgentIntegrationPreflightInput, + AgentIntegrationPreflightResult, + AgentResumePlanInput, + AgentStartPlanInput, + ProviderLaunchPlan, + SessionConfigUpdateInput, + SessionConfigUpdatePlan, +} from "../../../../application/ports/outbound/agent-integration-port.ts"; +import type { ThreadScope } from "../../../../application/domains/thread/thread.ts"; +import { npmInstallReadinessTerminalAction } from "../shared/provider-cli-commands.ts"; + +// Qwen Code speaks ACP over stdio with `qwen --acp` (the same transport family as +// opencode, with provider-specific launch/readiness only). The shared ACP client +// owns initialize/session/new/session/load/prompt, permission prompts, and model +// catalog updates. + +export interface QwenProviderState { + authenticated: boolean; +} + +export type QwenExecutableResolver = ( + command: "qwen" | "npm", +) => Promise | string | undefined; + +export type QwenProviderStateReader = (input: { + cwd: string; + executablePath: string; +}) => Promise | QwenProviderState; + +export interface QwenTideMcpConfig { + command: string; + args: string[]; + env?: Record; +} + +export interface CreateQwenAgentIntegrationInput { + resolveExecutable: QwenExecutableResolver; + readProviderState: QwenProviderStateReader; + defaultCwd?: string; + tideMcp?: QwenTideMcpConfig; +} + +const qwenCapabilities: AgentIntegrationCapabilities = { + supportsResume: true, + supportsTideMcp: true, + supportsHooks: false, + supportsReadableHistory: false, + supportsTurnSteer: false, +}; + +export function createQwenAgentIntegration( + input: CreateQwenAgentIntegrationInput, +): AgentIntegrationPort { + return new QwenAgentIntegration(input); +} + +class QwenAgentIntegration implements AgentIntegrationPort { + private readonly resolveExecutable: QwenExecutableResolver; + private readonly readProviderState: QwenProviderStateReader; + private readonly defaultCwd: string; + private readonly tideMcp?: QwenTideMcpConfig; + + constructor(input: CreateQwenAgentIntegrationInput) { + this.resolveExecutable = input.resolveExecutable; + this.readProviderState = input.readProviderState; + this.defaultCwd = input.defaultCwd ?? "."; + this.tideMcp = input.tideMcp; + } + + async preflight( + input: AgentIntegrationPreflightInput, + ): Promise { + const cwd = cwdFromScope(input.scope, this.defaultCwd); + const executablePath = await this.resolveExecutable("qwen"); + if (executablePath === undefined) { + const npmPath = (await this.resolveExecutable("npm")) ?? "npm"; + return { + agentId: "qwen", + ready: false, + blockers: [ + { + kind: "not_installed", + scope: "provider", + message: "Qwen Code executable was not found.", + terminalAction: npmInstallReadinessTerminalAction({ npmPath, agentId: "qwen", cwd }), + }, + ], + capabilities: qwenCapabilities, + }; + } + + const providerState = await this.readProviderState({ cwd, executablePath }); + if (!providerState.authenticated) { + return { + agentId: "qwen", + ready: false, + blockers: [ + { + kind: "not_authenticated", + scope: "provider", + message: + "Qwen Code has no model credentials configured. Run `qwen`, then use `/auth` " + + "or configure ~/.qwen/settings.json / .qwen/.env.", + terminalAction: { + command: executablePath, + args: [], + cwd, + expectedCompletion: "retry_preflight", + }, + }, + ], + capabilities: qwenCapabilities, + }; + } + + return { + agentId: "qwen", + ready: true, + blockers: [], + capabilities: qwenCapabilities, + launchPlan: this.qwenLaunchPlan({ executablePath, cwd, launchOptions: input.launchOptions }), + }; + } + + async buildStartPlan(input: AgentStartPlanInput): Promise { + const executablePath = (await this.resolveExecutable("qwen")) ?? "qwen"; + const cwd = cwdFromScope(input.scope, this.defaultCwd); + return this.qwenLaunchPlan({ executablePath, cwd, launchOptions: input.launchOptions }); + } + + async buildResumePlan(input: AgentResumePlanInput): Promise { + const executablePath = (await this.resolveExecutable("qwen")) ?? "qwen"; + const cwd = cwdFromScope(input.scope, this.defaultCwd); + return this.qwenLaunchPlan({ executablePath, cwd, launchOptions: input.launchOptions }); + } + + buildSessionConfigUpdate(input: SessionConfigUpdateInput): SessionConfigUpdatePlan { + return { + kind: "live", + protocolParams: qwenProtocolParams(input.launchOptions, input.changedKeys), + }; + } + + private qwenLaunchPlan(input: { + executablePath: string; + cwd: string; + launchOptions?: Record; + }): ProviderLaunchPlan { + return { + command: input.executablePath, + args: ["--acp"], + env: {}, + cwd: input.cwd, + transport: "acp", + protocolParams: { + cwd: input.cwd, + ...qwenProtocolParams(input.launchOptions, ["model", "permission"]), + ...(this.tideMcp !== undefined + ? { + mcpServers: [ + { + name: "tide", + command: this.tideMcp.command, + args: this.tideMcp.args, + env: Object.entries(this.tideMcp.env ?? {}).map(([name, value]) => ({ name, value })), + }, + ], + } + : {}), + }, + }; + } +} + +function cwdFromScope(scope: ThreadScope | undefined, fallback: string): string { + if (scope === undefined) { + return fallback; + } + return scope.kind === "project" ? scope.cwd : scope.scratchCwd; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function qwenProtocolParams( + launchOptions: Record | undefined, + changedKeys: ReadonlyArray, +): Record { + const params: Record = {}; + const configOptions = qwenConfigOptions(launchOptions, changedKeys); + if (configOptions.length > 0) { + params.configOptions = configOptions; + } + if (changedKeys.includes("permission")) { + const permission = stringValue(launchOptions?.permission); + if (permission !== undefined) { + params.modeId = permission; + } + } + return params; +} + +export function qwenConfigOptions( + launchOptions: Record | undefined, + changedKeys: ReadonlyArray, +): Array<{ configId: string; value: string }> { + if (!changedKeys.includes("model")) { + return []; + } + const model = stringValue(launchOptions?.model); + return model === undefined || model === "Qwen default" + ? [] + : [{ configId: "model", value: model }]; +} diff --git a/apps/desktop/src/backend/adapters/outbound/agent-integrations/shared/provider-cli-commands.ts b/apps/desktop/src/backend/adapters/outbound/agent-integrations/shared/provider-cli-commands.ts index c03fbad7..28b698ad 100644 --- a/apps/desktop/src/backend/adapters/outbound/agent-integrations/shared/provider-cli-commands.ts +++ b/apps/desktop/src/backend/adapters/outbound/agent-integrations/shared/provider-cli-commands.ts @@ -1,6 +1,7 @@ import { execFile, spawnSync } from "node:child_process"; import { promisify } from "node:util"; +import type { ProviderCliAgentId } from "../../../../application/domains/thread/thread.ts"; import type { ProviderReadinessTerminalAction } from "../../../../application/ports/outbound/agent-integration-port.ts"; // Provider CLI executable knowledge (audit A5/5.2): owned by the agent // integrations, consumed by infrastructure when spawning runtimes. @@ -15,10 +16,11 @@ const providerCliCommands = { codex: "codex", claude: "claude", opencode: "opencode", + qwen: "qwen", } as const; export function executableForAgent( - agentId: "codex" | "claude" | "opencode", + agentId: ProviderCliAgentId, ): string { return providerCliCommands[agentId]; } @@ -30,10 +32,11 @@ const providerInstallPackages = { codex: "@openai/codex", claude: "@anthropic-ai/claude-code", opencode: "opencode-ai", + qwen: "@qwen-code/qwen-code", } as const; export function installPackageForAgent( - agentId: "codex" | "claude" | "opencode", + agentId: ProviderCliAgentId, ): string { return providerInstallPackages[agentId]; } @@ -44,7 +47,7 @@ export function installPackageForAgent( // to "npm" so a missing npm surfaces its own PATH error in the terminal, not silently. export function npmInstallReadinessTerminalAction(input: { npmPath: string; - agentId: "codex" | "claude" | "opencode"; + agentId: ProviderCliAgentId; cwd: string; }): ProviderReadinessTerminalAction { return { @@ -72,7 +75,7 @@ export function resolveExecutable(command: string): string | undefined { // update counterpart of npmInstallReadinessTerminalAction. Spec: version-management.md. export function npmUpdateReadinessTerminalAction(input: { npmPath: string; - agentId: "codex" | "claude" | "opencode"; + agentId: ProviderCliAgentId; cwd: string; }): ProviderReadinessTerminalAction { return { diff --git a/apps/desktop/src/backend/adapters/outbound/agent-runtime/structured/acp-client.ts b/apps/desktop/src/backend/adapters/outbound/agent-runtime/structured/acp-client.ts index 5a761075..2f174561 100644 --- a/apps/desktop/src/backend/adapters/outbound/agent-runtime/structured/acp-client.ts +++ b/apps/desktop/src/backend/adapters/outbound/agent-runtime/structured/acp-client.ts @@ -229,10 +229,9 @@ class AcpClient implements StructuredRuntimeClient { // MERGE by configId: each change carries only its changed keys, so a later // pre-adoption change must not clobber an earlier one (e.g. model then effort). this.pendingConfigOptions = mergeConfigOptions(this.pendingConfigOptions, configOptions); - return; + } else { + this.sendConfigOptions(this.sessionId, configOptions); } - this.sendConfigOptions(this.sessionId, configOptions); - return; } const modeId = stringField(protocolParams, "modeId"); if (modeId === undefined) { diff --git a/apps/desktop/src/backend/application/domains/thread/thread.ts b/apps/desktop/src/backend/application/domains/thread/thread.ts index 12323f48..0e35f0c9 100644 --- a/apps/desktop/src/backend/application/domains/thread/thread.ts +++ b/apps/desktop/src/backend/application/domains/thread/thread.ts @@ -7,7 +7,7 @@ import type { WorkbenchState, } from "../workbench/workbench.ts"; -export type ProviderCliAgentId = "codex" | "claude" | "opencode"; +export type ProviderCliAgentId = "codex" | "claude" | "opencode" | "qwen"; export type AgentId = ProviderCliAgentId; export type ThreadId = string; export type ProjectId = string; @@ -22,6 +22,7 @@ export interface ProviderSessionRef { | "codex_rollout" | "claude_transcript" | "opencode_session" + | "qwen_session" | "provider_native"; value: string; transcriptPath?: string; diff --git a/apps/desktop/src/backend/application/services/thread/thread-persistence-service.ts b/apps/desktop/src/backend/application/services/thread/thread-persistence-service.ts index 9138bdef..f963341c 100644 --- a/apps/desktop/src/backend/application/services/thread/thread-persistence-service.ts +++ b/apps/desktop/src/backend/application/services/thread/thread-persistence-service.ts @@ -43,6 +43,7 @@ export interface ProviderSessionRefRecord { | "codex_rollout" | "claude_transcript" | "opencode_session" + | "qwen_session" | "provider_native"; value: string; transcriptPath?: string; diff --git a/apps/desktop/src/backend/infrastructure/node/live/live-backend.ts b/apps/desktop/src/backend/infrastructure/node/live/live-backend.ts index 0a48a2e2..d5817f7d 100644 --- a/apps/desktop/src/backend/infrastructure/node/live/live-backend.ts +++ b/apps/desktop/src/backend/infrastructure/node/live/live-backend.ts @@ -45,6 +45,7 @@ import { readClaudeProviderStateFromHome, readCodexProviderStateFromHome, readOpencodeProviderStateFromHome, + readQwenProviderStateFromHome, } from "../provider/provider-state-readers.ts"; import { @@ -86,6 +87,7 @@ export { export { readClaudeProviderStateFromHome, readCodexProviderStateFromHome, + readQwenProviderStateFromHome, }; import { createBackendContractMessageAdapter } from "../../../adapters/inbound/contract-message-adapter/contract-message-adapter.ts"; @@ -123,6 +125,7 @@ import { } from "../../../adapters/outbound/agent-integrations/codex/codex-agent-integration.ts"; import { createOpencodeAgentIntegration } from "../../../adapters/outbound/agent-integrations/opencode/opencode-agent-integration.ts"; +import { createQwenAgentIntegration } from "../../../adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts"; import { createProviderDetection } from "../provider/provider-detection.ts"; import { codexRolloutTurnEnded as codexRolloutTurnEndedFromText } from "../../../adapters/outbound/agent-integrations/codex/codex-rollout-turn-detection.ts"; @@ -273,6 +276,16 @@ export function createLiveBackendContractMessageAdapter( env: { TIDE_SOCKET: tideSocket }, }, }), + qwen: createQwenAgentIntegration({ + resolveExecutable: () => resolveExecutable("qwen"), + readProviderState: ({ cwd }) => readQwenProviderStateFromHome(homeDir, cwd, env), + defaultCwd: process.cwd(), + tideMcp: { + command: bootstrapArtifacts.tideMcpCommandPath, + args: [], + env: { TIDE_SOCKET: tideSocket }, + }, + }), }; // Background agent-CLI update detection (spec: version-management.md, Lane 2). const agentUpdateChecker = createLiveAgentUpdateChecker({ diff --git a/apps/desktop/src/backend/infrastructure/node/live/resolve-shell-path.ts b/apps/desktop/src/backend/infrastructure/node/live/resolve-shell-path.ts index 5ec40208..488b3677 100644 --- a/apps/desktop/src/backend/infrastructure/node/live/resolve-shell-path.ts +++ b/apps/desktop/src/backend/infrastructure/node/live/resolve-shell-path.ts @@ -3,7 +3,7 @@ import os from "node:os"; // A packaged macOS/Linux app launched from Finder/Dock does NOT inherit the // user's login-shell PATH — it only gets the minimal launchd PATH -// (/usr/bin:/bin:/usr/sbin:/sbin). Provider CLIs (codex, claude, opencode) and their +// (/usr/bin:/bin:/usr/sbin:/sbin). Provider CLIs (codex, claude, opencode, qwen) and their // helpers live in places like ~/.local/bin, /opt/homebrew/bin, or a provider's // own standalone bin, none of which are on that minimal PATH. `which ` then // fails, the Agent Runtime never finds an executable, and no provider ever spawns. diff --git a/apps/desktop/src/backend/infrastructure/node/provider/provider-state-readers.ts b/apps/desktop/src/backend/infrastructure/node/provider/provider-state-readers.ts index e99b4b34..c458fa20 100644 --- a/apps/desktop/src/backend/infrastructure/node/provider/provider-state-readers.ts +++ b/apps/desktop/src/backend/infrastructure/node/provider/provider-state-readers.ts @@ -1,14 +1,15 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { ClaudeProviderState } from "../../../adapters/outbound/agent-integrations/claude/claude-agent-integration.ts"; import type { CodexProviderState } from "../../../adapters/outbound/agent-integrations/codex/codex-agent-integration.ts"; +import type { QwenProviderState } from "../../../adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts"; import { isClaudeBootstrapReady, isMcpBootstrapReady, providerBootstrapArtifactsForHome, } from "./provider-bootstrap-artifacts.ts"; import { readJsonFile, readTextFile } from "../live/live-backend-fs.ts"; -import { arrayOfStrings, recordField, stringField } from "../live/live-backend-json.ts"; +import { recordField, stringField, unknownRecord } from "../live/live-backend-json.ts"; // Reads provider-owned readiness state (auth, onboarding, directory trust, hook/ // plugin bootstrap) from the user's home directory for each provider CLI. @@ -63,6 +64,22 @@ export function readOpencodeProviderStateFromHome( return { authenticated: auth !== undefined && Object.keys(auth).length > 0 }; } +export function readQwenProviderStateFromHome( + homeDir: string, + cwd: string, + env: NodeJS.ProcessEnv = process.env, +): QwenProviderState { + const settings = readJsonFile(join(homeDir, ".qwen", "settings.json")); + const settingsEnv = recordField(settings, "env"); + const credentialEnvKeys = qwenCredentialEnvKeys(settings); + return { + authenticated: + hasQwenCredential(env, credentialEnvKeys) || + hasQwenCredential(settingsEnv, credentialEnvKeys) || + hasQwenDotEnvCredential(cwd, homeDir, credentialEnvKeys), + }; +} + function hasCodexAuth(value: Record | undefined): boolean { if (value === undefined) { return false; @@ -92,3 +109,135 @@ function codexTrustedCwds(config: string): string[] { return trusted; } + +const DEFAULT_QWEN_CREDENTIAL_ENV_KEYS = [ + "BAILIAN_CODING_PLAN_API_KEY", + "DASHSCOPE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "REQUESTY_API_KEY", + "MODELSCOPE_API_TOKEN", + "DEEPSEEK_API_KEY", + "MINIMAX_API_KEY", + "ZAI_API_KEY", +]; + +function qwenCredentialEnvKeys(settings: Record | undefined): Set { + const keys = new Set(DEFAULT_QWEN_CREDENTIAL_ENV_KEYS); + const providers = recordField(settings, "modelProviders"); + if (providers !== undefined) { + for (const provider of Object.values(providers)) { + const models = unknownRecord(provider)?.models; + if (!Array.isArray(models)) { + continue; + } + for (const model of models) { + const envKey = stringField(unknownRecord(model), "envKey"); + if (envKey !== undefined) { + keys.add(envKey); + } + } + } + } + const settingsEnv = recordField(settings, "env"); + if (settingsEnv !== undefined) { + for (const key of Object.keys(settingsEnv)) { + if (/(API_KEY|TOKEN)$/i.test(key)) { + keys.add(key); + } + } + } + return keys; +} + +function hasQwenCredential( + value: Record | undefined, + envKeys: ReadonlySet, +): boolean { + if (value === undefined) { + return false; + } + const normalizedKeys = new Set([...envKeys].map((key) => key.toUpperCase())); + return Object.entries(value).some(([key, raw]) => { + if (!normalizedKeys.has(key.toUpperCase()) || typeof raw !== "string") { + return false; + } + const credential = raw.trim(); + return credential.length > 0 && !/^(YOUR_|sk-?x+$|x+$)/i.test(credential); + }); +} + +function hasQwenDotEnvCredential( + cwd: string, + homeDir: string, + envKeys: ReadonlySet, +): boolean { + const projectDotEnv = firstReadableDotEnv(qwenProjectDotEnvPaths(cwd), envKeys); + if (projectDotEnv !== undefined) { + return projectDotEnv; + } + return firstReadableDotEnv([ + join(homeDir, ".qwen", ".env"), + join(homeDir, ".env"), + ], envKeys) ?? false; +} + +function firstReadableDotEnv( + filePaths: string[], + envKeys: ReadonlySet, +): boolean | undefined { + for (const filePath of filePaths) { + const text = readTextFile(filePath); + if (text !== undefined) { + return hasQwenCredential(parseDotEnv(text), envKeys); + } + } + return undefined; +} + +function qwenProjectDotEnvPaths(cwd: string): string[] { + const paths: string[] = []; + let current = cwd; + while (current.length > 0) { + paths.push(join(current, ".qwen", ".env"), join(current, ".env")); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return [...new Set(paths)]; +} + +function parseDotEnv(text: string | undefined): Record | undefined { + if (text === undefined) { + return undefined; + } + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (match === null) { + continue; + } + const [, key, rawValue] = match; + if (key === undefined || rawValue === undefined) { + continue; + } + env[key] = stripEnvQuotes(rawValue); + } + return env; +} + +function stripEnvQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} diff --git a/apps/desktop/src/desktop/adapters/inbound/react-renderer/agent-chat/composer/choice-surface.tsx b/apps/desktop/src/desktop/adapters/inbound/react-renderer/agent-chat/composer/choice-surface.tsx index 655fa17f..820f2343 100644 --- a/apps/desktop/src/desktop/adapters/inbound/react-renderer/agent-chat/composer/choice-surface.tsx +++ b/apps/desktop/src/desktop/adapters/inbound/react-renderer/agent-chat/composer/choice-surface.tsx @@ -1,6 +1,7 @@ import type { AgentChatChoiceSurfaceView } from "../../../../../application/domains/agent-chat/agent-chat.ts"; import { useEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from "react"; import { Bot, Check, FileText, Folder, FolderPlus, GitBranch, Layers, PanelsTopLeft, Paperclip, Plus, Trash2, Wrench } from "lucide-react"; +import { agentDescriptor } from "../../../../../../shared/agent-descriptors.ts"; // Extracted from agent-chat-shell.ts (spec: navigable-source-structure). export function createChoiceSurface(input: { @@ -304,14 +305,7 @@ function inlineCreateConfig( // Two-letter provider monogram (Codex/Claude both start with C, hence distinct // 2-char codes). Mirrors agentMonogram() in tide-product-shell. export function agentMonogramFor(agentId: string): string { - switch (agentId) { - case "claude": - return "Cl"; - case "opencode": - return "Oc"; - default: - return "Co"; - } + return agentDescriptor(agentId)?.monogram ?? "Co"; } function choiceRowIcon(icon: string | undefined): ReactNode { diff --git a/apps/desktop/src/desktop/adapters/inbound/react-renderer/product-shell/support/agent-identity.tsx b/apps/desktop/src/desktop/adapters/inbound/react-renderer/product-shell/support/agent-identity.tsx index d23f021a..3afc99c5 100644 --- a/apps/desktop/src/desktop/adapters/inbound/react-renderer/product-shell/support/agent-identity.tsx +++ b/apps/desktop/src/desktop/adapters/inbound/react-renderer/product-shell/support/agent-identity.tsx @@ -25,11 +25,8 @@ export function AgentIdentityIcon(props: { agentId: ProductShellAgentIdentity | } function normalizeAgentId(agentId: string): ProductShellAgentIdentity { - if ( - agentId === "claude" || - agentId === "opencode" - ) { - return agentId; + if (agentDescriptor(agentId)?.isProviderCli === true) { + return agentId as ProductShellAgentIdentity; } return "codex"; } diff --git a/apps/desktop/src/desktop/application/domains/agent-chat/state/agent-vocab.ts b/apps/desktop/src/desktop/application/domains/agent-chat/state/agent-vocab.ts index 614a9771..fecea734 100644 --- a/apps/desktop/src/desktop/application/domains/agent-chat/state/agent-vocab.ts +++ b/apps/desktop/src/desktop/application/domains/agent-chat/state/agent-vocab.ts @@ -1,7 +1,9 @@ import type { AgentChatAgentBinding, AgentChatAgentId, AgentChatAgentRuntimeSource } from "./types.ts"; import { AGENT_DESCRIPTORS, + PROVIDER_CLI_AGENT_IDS, agentDescriptor, + isProviderCliAgentId, type AgentPermissionConfig, } from "../../../../../shared/agent-descriptors.ts"; // Extracted from agent-chat-shell-state.ts (spec: navigable-source-structure). @@ -91,7 +93,7 @@ export function isAgentComingSoon(agentId: string): boolean { } // Provider-CLI agents offered in the composer menu. -const OFFERED_PROVIDER_AGENTS = ["codex", "claude", "opencode"] as const; +const OFFERED_PROVIDER_AGENTS = PROVIDER_CLI_AGENT_IDS; // Pick the agent a new thread should default to. Honors the user's last choice only if // it is still offered AND detected locally — so a persisted hidden/uninstalled agent @@ -171,6 +173,11 @@ export function cliModelOptionsForAgent(agentId: string): CliModelOption[] { const catalog = providerModelCatalogs.get("opencode"); return catalog === undefined ? [fallback] : [fallback, ...catalog]; } + case "qwen": { + const fallback = { value: "Qwen default", label: "Default", detail: "qwen config" }; + const catalog = providerModelCatalogs.get("qwen"); + return catalog === undefined ? [fallback] : [fallback, ...catalog]; + } default: return []; } @@ -191,10 +198,7 @@ export function runtimeSourceForBinding(binding: AgentChatAgentBinding): AgentCh } export function runtimeSourceForAgent(agentId: string): AgentChatAgentRuntimeSource { - const providerAgent = - agentId === "claude" || agentId === "opencode" - ? agentId - : "codex"; + const providerAgent = isProviderCliAgentId(agentId) ? agentId : "codex"; return { kind: "provider_cli", integrationId: providerAgent, @@ -207,6 +211,8 @@ export function defaultModelValueForAgent(agentId: string): string { return "Claude default"; case "opencode": return "opencode default"; + case "qwen": + return "Qwen default"; default: return "gpt-5.5"; } diff --git a/apps/desktop/src/desktop/application/domains/agent-chat/state/choice-surfaces.ts b/apps/desktop/src/desktop/application/domains/agent-chat/state/choice-surfaces.ts index 47cfb9b8..99219bcb 100644 --- a/apps/desktop/src/desktop/application/domains/agent-chat/state/choice-surfaces.ts +++ b/apps/desktop/src/desktop/application/domains/agent-chat/state/choice-surfaces.ts @@ -6,6 +6,7 @@ import { launchOptionsForState, setComposerNewWorktreeIntent, updateComposerLaun import { buildOpencodeConnectSurface, getOpencodeEnvironment, isOpencodeUsable } from "./opencode-onramp.ts"; import { basenameOf } from "./path-labels.ts"; import { row } from "./choice-row.ts"; +import { PROVIDER_CLI_AGENT_IDS, isProviderCliAgentId } from "../../../../../shared/agent-descriptors.ts"; // Extracted from agent-chat-shell-state.ts (spec: navigable-source-structure). const CODEX_LOCAL_SLASH_COMMANDS: AgentChatCommandOption[] = [ @@ -283,11 +284,9 @@ export function createActiveComposerSurface( sourceLabel: "Agent Binding", // Provider-CLI agents are listed; ones whose CLI is not detected on the local // system are shown DISABLED (greyed), never removed. - rows: [ - agentMenuRow("codex", "Codex CLI", binding.agentId), - agentMenuRow("claude", "Claude Code", binding.agentId), - agentMenuRow("opencode", "opencode", binding.agentId), - ], + rows: PROVIDER_CLI_AGENT_IDS.map((agentId) => + agentMenuRow(agentId, formatAgentLabel(agentId), binding.agentId), + ), }; case "model_menu": return { @@ -515,16 +514,7 @@ function agentMenuRow( function composerAgentIdForRow( rowId: string, ): AgentChatAgentId | null { - switch (rowId) { - case "codex": - return "codex"; - case "claude": - return "claude"; - case "opencode": - return "opencode"; - default: - return null; - } + return isProviderCliAgentId(rowId) ? rowId : null; } function modelForRow(rowId: string): string | undefined { diff --git a/apps/desktop/src/desktop/application/domains/agent-chat/state/providers-hub.ts b/apps/desktop/src/desktop/application/domains/agent-chat/state/providers-hub.ts index 79d550e6..93a0c573 100644 --- a/apps/desktop/src/desktop/application/domains/agent-chat/state/providers-hub.ts +++ b/apps/desktop/src/desktop/application/domains/agent-chat/state/providers-hub.ts @@ -6,6 +6,7 @@ import { permissionConfigForAgent, } from "./agent-vocab.ts"; import { getOpencodeEnvironment, getOpencodeVendors } from "./opencode-onramp.ts"; +import { PROVIDER_CLI_AGENT_IDS } from "../../../../../shared/agent-descriptors.ts"; // The data layer for the Settings "Providers & Models" hub: one row per provider-CLI // agent with its install status, model catalog (the same catalog the composer menu @@ -39,7 +40,7 @@ export interface ProvidersHubAgentView { version?: string; } -const HUB_AGENTS = ["claude", "codex", "opencode"] as const; +const HUB_AGENTS = PROVIDER_CLI_AGENT_IDS; export function buildProvidersHubViewModel(): ProvidersHubAgentView[] { return HUB_AGENTS.map((agentId) => { diff --git a/apps/desktop/src/desktop/application/domains/agent-chat/state/types.ts b/apps/desktop/src/desktop/application/domains/agent-chat/state/types.ts index e7f5440a..a85e168c 100644 --- a/apps/desktop/src/desktop/application/domains/agent-chat/state/types.ts +++ b/apps/desktop/src/desktop/application/domains/agent-chat/state/types.ts @@ -190,7 +190,7 @@ export interface AgentChatAgentBinding { }; } -export type AgentChatProviderCliAgentId = "codex" | "claude" | "opencode"; +export type AgentChatProviderCliAgentId = "codex" | "claude" | "opencode" | "qwen"; export type AgentChatAgentId = AgentChatProviderCliAgentId; diff --git a/apps/desktop/src/desktop/application/domains/product-shell/state/start.ts b/apps/desktop/src/desktop/application/domains/product-shell/state/start.ts index 4f06eee8..81815b5e 100644 --- a/apps/desktop/src/desktop/application/domains/product-shell/state/start.ts +++ b/apps/desktop/src/desktop/application/domains/product-shell/state/start.ts @@ -241,7 +241,7 @@ function preferredWorktreeForPersistence(value: unknown): "current folder" | "ne return undefined; } -// The four provider-CLI agent identities the Start Composer can launch. Single source of +// The provider-CLI agent identities the Start Composer can launch. Single source of // truth for "is this a known agent" guards (preference persistence) so they can't // drift into a stale subset — the bug that silently dropped opencode from // the remembered Start Composer default. @@ -249,6 +249,7 @@ const PRODUCT_SHELL_AGENT_IDENTITIES: ReadonlySet = new Set = { legacyValueMap: { default: "build", auto_edit: "build", yolo: "build" }, }, }, + qwen: { + id: "qwen", + displayName: "Qwen Code", + monogram: "Qw", + isProviderCli: true, + sessionRefKind: "qwen_session", + permission: { + default: "default", + options: [ + { id: "qwen-ask", value: "default", label: "Ask permissions", detail: "Approve edits & shell" }, + { id: "qwen-auto-edit", value: "auto-edit", label: "Auto edits", detail: "Auto-approve file edits" }, + { id: "qwen-auto", value: "auto", label: "Auto mode", detail: "Classifier-based approvals" }, + { id: "qwen-plan", value: "plan", label: "Plan mode", detail: "Read-only analysis" }, + { id: "qwen-yolo", value: "yolo", label: "YOLO mode", detail: "Auto-approve all tools", danger: true }, + ], + legacyValueMap: { auto_edit: "auto-edit", ask: "default" }, + }, + }, }; // All provider-CLI agent ids, derived from the table (never hand-listed elsewhere). diff --git a/apps/desktop/src/shared/contracts/agent.ts b/apps/desktop/src/shared/contracts/agent.ts index d6822f6d..343bf33c 100644 --- a/apps/desktop/src/shared/contracts/agent.ts +++ b/apps/desktop/src/shared/contracts/agent.ts @@ -1,4 +1,4 @@ -export type ProviderCliAgentId = "codex" | "claude" | "opencode"; +export type ProviderCliAgentId = "codex" | "claude" | "opencode" | "qwen"; export type AgentId = ProviderCliAgentId; export interface AgentRuntimeSourceDto { @@ -17,6 +17,7 @@ export interface ProviderSessionRefDto { | "codex_rollout" | "claude_transcript" | "opencode_session" + | "qwen_session" | "provider_native"; value: string; transcriptPath?: string; diff --git a/apps/desktop/src/shared/contracts/envelopes.ts b/apps/desktop/src/shared/contracts/envelopes.ts index 7dd3dc9c..d2899a71 100644 --- a/apps/desktop/src/shared/contracts/envelopes.ts +++ b/apps/desktop/src/shared/contracts/envelopes.ts @@ -413,7 +413,7 @@ function isKnownAgentId(value: string): boolean { } function isProviderCliAgentId(value: string): boolean { - return value === "codex" || value === "claude" || value === "opencode"; + return value === "codex" || value === "claude" || value === "opencode" || value === "qwen"; } function isNonEmptyString(value: unknown): value is string { diff --git a/apps/desktop/tests/agent-symmetry-boundary.test.ts b/apps/desktop/tests/agent-symmetry-boundary.test.ts index fde9a553..aa103012 100644 --- a/apps/desktop/tests/agent-symmetry-boundary.test.ts +++ b/apps/desktop/tests/agent-symmetry-boundary.test.ts @@ -32,7 +32,7 @@ function read(rel: string): string { // compile-time union; this is its runtime twin, and the descriptor table + helpers // must agree with it. Adding an id to the type without updating this list (and a // descriptor) is the exact fan-out this guard prevents. -const EXPECTED_PROVIDER_CLI_IDS = ["codex", "claude", "opencode"] as const; +const EXPECTED_PROVIDER_CLI_IDS = ["codex", "claude", "opencode", "qwen"] as const; test("every provider-CLI agent id has a descriptor and a session-ref kind", () => { for (const id of EXPECTED_PROVIDER_CLI_IDS) { diff --git a/apps/desktop/tests/backend-agent-runtime-port-wiring.test.ts b/apps/desktop/tests/backend-agent-runtime-port-wiring.test.ts index 282a1bb7..3c2a7458 100644 --- a/apps/desktop/tests/backend-agent-runtime-port-wiring.test.ts +++ b/apps/desktop/tests/backend-agent-runtime-port-wiring.test.ts @@ -127,6 +127,7 @@ import type { AgentRuntimeResumeInput, TerminalInput, } from "../src/backend/application/domains/agent-runtime/agent-runtime.ts"; +import type { ProviderCliAgentId } from "../src/backend/application/domains/thread/thread.ts"; import type { AgentRuntimePort } from "../src/backend/application/ports/outbound/agent-runtime-port.ts"; import type { ProviderReadinessPort } from "../src/backend/application/ports/outbound/provider-readiness-port.ts"; import type { PtyTranscriptPort } from "../src/backend/application/ports/outbound/pty-transcript-port.ts"; @@ -144,8 +145,9 @@ test("provider_readiness_port_uses_selected_agent_integration_preflight", async const codex = fakeIntegration("codex", startPlan("codex")); const claude = fakeIntegration("claude", startPlan("claude")); const opencode = fakeIntegration("opencode", startPlan("opencode")); + const qwen = fakeIntegration("qwen", startPlan("qwen")); const readiness = createAgentIntegrationProviderReadinessPort({ - integrations: { codex, claude, opencode }, + integrations: { codex, claude, opencode, qwen }, }); const result = await readiness.check({ @@ -169,8 +171,9 @@ test("provider_readiness_port_rejects_non_provider_cli_agent", async () => { const codex = fakeIntegration("codex", startPlan("codex")); const claude = fakeIntegration("claude", startPlan("claude")); const opencode = fakeIntegration("opencode", startPlan("opencode")); + const qwen = fakeIntegration("qwen", startPlan("qwen")); const readiness = createAgentIntegrationProviderReadinessPort({ - integrations: { codex, claude, opencode }, + integrations: { codex, claude, opencode, qwen }, }); const result = await readiness.check({ @@ -192,6 +195,7 @@ test("provider_readiness_port_rejects_non_provider_cli_agent", async () => { assert.equal(codex.preflightInputs.length, 0); assert.equal(claude.preflightInputs.length, 0); assert.equal(opencode.preflightInputs.length, 0); + assert.equal(qwen.preflightInputs.length, 0); }); // Spec: docs_v2/specs/mid-thread-launch-option-changes.md — a session-config @@ -201,8 +205,9 @@ test("apply_session_config_without_a_live_runtime_requires_restart", async () => const codex = fakeIntegration("codex", startPlan("codex")); const claude = fakeIntegration("claude", startPlan("claude")); const opencode = fakeIntegration("opencode", startPlan("opencode")); + const qwen = fakeIntegration("qwen", startPlan("qwen")); const port = createAgentIntegrationAgentRuntimePort({ - integrations: { codex, claude, opencode }, + integrations: { codex, claude, opencode, qwen }, }); const result = await port.applySessionConfig( @@ -1210,7 +1215,7 @@ test("agent_runtime_wiring_stays_out_of_desktop_and_shared_contracts", () => { }); function fakeIntegration( - agentId: "codex" | "claude" | "opencode", + agentId: ProviderCliAgentId, plan: ProviderLaunchPlan, ) { return new FakeAgentIntegration(agentId, plan); @@ -1220,12 +1225,12 @@ class FakeAgentIntegration implements AgentIntegrationPort { preflightInputs: AgentIntegrationPreflightInput[] = []; startInputs: AgentStartPlanInput[] = []; resumeInputs: AgentResumePlanInput[] = []; - private readonly agentId: "codex" | "claude" | "opencode"; + private readonly agentId: ProviderCliAgentId; private readonly plan: ProviderLaunchPlan; readinessGate: RuntimeReadinessGate = { kind: "immediate" }; constructor( - agentId: "codex" | "claude" | "opencode", + agentId: ProviderCliAgentId, plan: ProviderLaunchPlan, ) { this.agentId = agentId; @@ -1371,7 +1376,7 @@ function readyProviderReadinessPort(): ProviderReadinessPort { function liveProviderThreadSeed(input: { threadId: string; runtimeId: string; - agentId: "codex" | "claude" | "opencode"; + agentId: ProviderCliAgentId; }): ThreadSeed { return { threadId: input.threadId, @@ -1395,15 +1400,15 @@ function liveProviderThreadSeed(input: { }; } -function startPlan(agentId: "codex" | "claude" | "opencode"): ProviderLaunchPlan { +function startPlan(agentId: ProviderCliAgentId): ProviderLaunchPlan { return { command: - agentId === "opencode" ? "opencode" : agentId === "claude" ? "claude" : "codex", - args: [], + agentId === "opencode" ? "opencode" : agentId === "qwen" ? "qwen" : agentId === "claude" ? "claude" : "codex", + args: agentId === "qwen" ? ["--acp"] : [], env: { TERM: "xterm-256color", COLORTERM: "truecolor" }, cwd: "/repo", transport: - agentId === "opencode" + agentId === "opencode" || agentId === "qwen" ? "acp" : agentId === "claude" ? "claude_stream_json" diff --git a/apps/desktop/tests/desktop-agent-chat-composer-shell.test.tsx b/apps/desktop/tests/desktop-agent-chat-composer-shell.test.tsx index f12a88bf..64438750 100644 --- a/apps/desktop/tests/desktop-agent-chat-composer-shell.test.tsx +++ b/apps/desktop/tests/desktop-agent-chat-composer-shell.test.tsx @@ -1740,12 +1740,16 @@ test("composer_shell_command_adapter_only_claims_shell_owned_backend_command_kin test("agent_chip_renders_one_visible_value_for_provider_cli_sources", () => { const codexHtml = renderShell(createAgentChatShellState()); const opencodeHtml = renderShell(selectComposerAgent(createAgentChatShellState(), "opencode").state); + const qwenHtml = renderShell(selectComposerAgent(createAgentChatShellState(), "qwen").state); assert.equal((codexHtml.match(/data-context-kind="agent"/g) ?? []).length, 1); assert.equal((opencodeHtml.match(/data-context-kind="agent"/g) ?? []).length, 1); + assert.equal((qwenHtml.match(/data-context-kind="agent"/g) ?? []).length, 1); assert.match(codexHtml, /Codex CLI/); assert.match(opencodeHtml, /opencode/); + assert.match(qwenHtml, /Qwen Code/); assert.match(opencodeHtml, /data-agent-runtime-source="provider_cli"/); + assert.match(qwenHtml, /data-agent-runtime-source="provider_cli"/); }); test("model_chip_routes_menu_data_by_provider_cli_agent", () => { @@ -1754,14 +1758,22 @@ test("model_chip_routes_menu_data_by_provider_cli_agent", () => { selectComposerAgent(createAgentChatShellState(), "opencode").state, "model_menu", ).state; + const qwenState = setComposerActiveSurface( + selectComposerAgent(createAgentChatShellState(), "qwen").state, + "model_menu", + ).state; const codexHtml = renderShell(codexState); const opencodeHtml = renderShell(opencodeState); + const qwenHtml = renderShell(qwenState); assert.match(codexHtml, /Model/); assert.match(codexHtml, /Codex Agent Integration/); assert.doesNotMatch(codexHtml, /OpenAI Provider Account/); assert.match(opencodeHtml, /opencode/); assert.match(opencodeHtml, /Add a vendor/); + assert.match(qwenHtml, /Qwen Code/); + assert.match(qwenHtml, /Default/); + assert.doesNotMatch(qwenHtml, /Add a vendor/); }); test("codex_model_chip_renders_polished_label_but_stores_provider_native_value", () => { @@ -1813,6 +1825,20 @@ test("selecting_opencode_uses_provider_cli_model_and_permission_defaults", () => assert.equal(view.composer.permissionLabel, "Build"); }); +test("selecting_qwen_uses_provider_cli_model_and_permission_defaults", () => { + const selected = selectComposerAgent(createAgentChatShellState(), "qwen").state; + const view = createAgentChatShellViewModel(selected); + + assert.equal(selected.composer.startOptions.agentBinding.agentId, "qwen"); + assert.deepEqual(selected.composer.startOptions.agentBinding.runtimeSource, { + kind: "provider_cli", + integrationId: "qwen", + }); + assert.equal(selected.composer.startOptions.launchOptions?.model, "Qwen default"); + assert.equal(view.composer.modelLabel, "Default"); + assert.equal(view.composer.permissionLabel, "Ask permissions"); +}); + test("follow_up_composer_model_label_uses_active_thread_launch_options", () => { // Spec: docs_v2/specs/thread-launch-options-contract.md const state = createAgentChatShellState({ @@ -1889,6 +1915,12 @@ test("permission_menu_renders_only_the_selected_agent_provider_values", () => { "permission_menu", ).state, ); + const qwenHtml = renderShell( + setComposerActiveSurface( + selectComposerAgent(createAgentChatShellState(), "qwen").state, + "permission_menu", + ).state, + ); // Codex mirrors the Codex app's 3 friendly approval modes (not raw CLI flags). assert.match(codexHtml, /Approve for me/); @@ -1904,6 +1936,12 @@ test("permission_menu_renders_only_the_selected_agent_provider_values", () => { assert.match(opencodeHtml, /Plan/); assert.doesNotMatch(opencodeHtml, /Tide tool policy/); assert.doesNotMatch(opencodeHtml, /Bypass permissions/); + // Qwen mirrors Qwen Code's ACP mode ids. + assert.match(qwenHtml, /Ask permissions/); + assert.match(qwenHtml, /Auto edits/); + assert.match(qwenHtml, /YOLO mode/); + assert.doesNotMatch(qwenHtml, /Build/); + assert.doesNotMatch(qwenHtml, /Bypass permissions/); }); test("codex_legacy_workspace_write_permission_preserves_sandbox_semantics", () => { @@ -2190,7 +2228,7 @@ test("every provider-agent row binds; a not-installed row is an intentional no-o // case, so it was a SILENT no-op). All provider-CLI agents bind — opencode // is no longer coming-soon. setAvailableProviderAgents(null); // all detected - for (const agentId of ["codex", "claude", "opencode"] as const) { + for (const agentId of ["codex", "claude", "opencode", "qwen"] as const) { const opened = setComposerActiveSurface(createAgentChatShellState(), "agent_menu").state; const selected = selectAgentChatChoiceSurfaceRow(opened, "agent_menu", agentId).state; assert.equal( @@ -2202,14 +2240,20 @@ test("every provider-agent row binds; a not-installed row is an intentional no-o // A NOT-INSTALLED agent is now SELECTABLE: picking it binds the agent so the handler can // ensure a Draft Thread + run provider.checkReadiness to surface its install / sign-in card. // (Only coming-soon rows stay disabled.) Spec: provider-cli-setup-handoff.md. - setAvailableProviderAgents(["codex", "claude"]); // opencode undetected + setAvailableProviderAgents(["codex", "claude"]); // opencode/qwen undetected const base = setComposerActiveSurface(createAgentChatShellState(), "agent_menu").state; const afterOpencode = selectAgentChatChoiceSurfaceRow(base, "agent_menu", "opencode").state; + const afterQwen = selectAgentChatChoiceSurfaceRow(base, "agent_menu", "qwen").state; assert.equal( afterOpencode.composer.startOptions.agentBinding.agentId, "opencode", "selecting a not-installed agent should bind it so the install handoff can run", ); + assert.equal( + afterQwen.composer.startOptions.agentBinding.agentId, + "qwen", + "selecting a not-installed agent should bind it so the install handoff can run", + ); // …and its row renders selectable (not greyed): only coming-soon rows are disabled. const undetectedRows = createAgentChatShellViewModel(base).composer.activeSurface?.rows ?? []; assert.equal( @@ -2217,6 +2261,11 @@ test("every provider-agent row binds; a not-installed row is an intentional no-o false, "a not-installed agent row must be selectable so its install handoff can start", ); + assert.equal( + undetectedRows.find((entry) => entry.rowId === "qwen")?.disabled, + false, + "a not-installed agent row must be selectable so its install handoff can start", + ); setAvailableProviderAgents(null); // reset module state for other tests }); diff --git a/apps/desktop/tests/opencode-model-vendor-selection.test.ts b/apps/desktop/tests/opencode-model-vendor-selection.test.ts index aeb96998..af7b8870 100644 --- a/apps/desktop/tests/opencode-model-vendor-selection.test.ts +++ b/apps/desktop/tests/opencode-model-vendor-selection.test.ts @@ -13,6 +13,7 @@ import { permissionConfigForAgent, setAvailableProviderAgents, setOpencodeModelCatalog, + setProviderModelCatalog, } from "../src/desktop/application/domains/agent-chat/state/agent-vocab.ts"; import { buildProvidersHubViewModel } from "../src/desktop/application/domains/agent-chat/state/providers-hub.ts"; @@ -177,10 +178,10 @@ test("parseAcpModelCatalog reads ACP availableModels and opencode configOptions" }); test("buildProvidersHubViewModel lists supported agents with status + catalog", () => { - setAvailableProviderAgents(["claude", "codex"]); // opencode not installed + setAvailableProviderAgents(["claude", "codex"]); // opencode/qwen not installed setOpencodeModelCatalog([{ value: "openai/gpt-5.5", label: "gpt-5.5", vendor: "openai" }]); const hub = buildProvidersHubViewModel(); - assert.deepEqual(hub.map((agent) => agent.agentId), ["claude", "codex", "opencode"]); + assert.deepEqual(hub.map((agent) => agent.agentId), ["codex", "claude", "opencode", "qwen"]); const opencode = hub.find((agent) => agent.agentId === "opencode"); assert.equal(opencode?.installed, false); @@ -188,6 +189,12 @@ test("buildProvidersHubViewModel lists supported agents with status + catalog", assert.equal(opencode?.multiVendor, true); // its catalog carries vendors assert.deepEqual(opencode?.permissionModes.map((mode) => mode.value), ["build", "plan"]); + const qwen = hub.find((agent) => agent.agentId === "qwen"); + assert.equal(qwen?.installed, false); + assert.equal(qwen?.status, "not_installed"); + assert.equal(qwen?.defaultModel, "Qwen default"); + assert.equal(qwen?.models[0]?.value, "Qwen default"); + const claude = hub.find((agent) => agent.agentId === "claude"); assert.equal(claude?.installed, true); assert.equal(claude?.multiVendor, false); @@ -219,3 +226,19 @@ test("cliModelOptionsForAgent('opencode') reflects the backend-enumerated catalo // Reset module state so other tests are unaffected. setOpencodeModelCatalog(null); }); + +test("cliModelOptionsForAgent('qwen') reflects the ACP-reported catalog", () => { + assert.deepEqual(cliModelOptionsForAgent("qwen"), [ + { value: "Qwen default", label: "Default", detail: "qwen config" }, + ]); + + setProviderModelCatalog("qwen", [ + { value: "qwen3-coder-plus", label: "Qwen3 Coder Plus", vendor: "alibaba" }, + ]); + assert.deepEqual(cliModelOptionsForAgent("qwen"), [ + { value: "Qwen default", label: "Default", detail: "qwen config" }, + { value: "qwen3-coder-plus", label: "Qwen3 Coder Plus", vendor: "alibaba" }, + ]); + + setProviderModelCatalog("qwen", null); +}); diff --git a/apps/desktop/tests/provider-cli-install-action.test.ts b/apps/desktop/tests/provider-cli-install-action.test.ts index c6db611d..03556888 100644 --- a/apps/desktop/tests/provider-cli-install-action.test.ts +++ b/apps/desktop/tests/provider-cli-install-action.test.ts @@ -14,6 +14,7 @@ test("installPackageForAgent maps every provider CLI to its npm package", () => assert.equal(installPackageForAgent("claude"), "@anthropic-ai/claude-code"); assert.equal(installPackageForAgent("codex"), "@openai/codex"); assert.equal(installPackageForAgent("opencode"), "opencode-ai"); + assert.equal(installPackageForAgent("qwen"), "@qwen-code/qwen-code"); }); test("npmInstallReadinessTerminalAction builds a global npm install that re-runs preflight", () => { diff --git a/apps/desktop/tests/qwen-provider-integration-bootstrap.test.ts b/apps/desktop/tests/qwen-provider-integration-bootstrap.test.ts new file mode 100644 index 00000000..e3fb179d --- /dev/null +++ b/apps/desktop/tests/qwen-provider-integration-bootstrap.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createQwenAgentIntegration, + qwenConfigOptions, + qwenProtocolParams, +} from "../src/backend/adapters/outbound/agent-integrations/qwen/qwen-agent-integration.ts"; +import { readQwenProviderStateFromHome } from "../src/backend/infrastructure/node/provider/provider-state-readers.ts"; + +// Spec: Qwen Code is an ACP-backed provider CLI. Tide owns the generic ACP +// runtime; Qwen owns only launch/readiness/config specifics. + +const projectScope = { kind: "project", projectId: "tide", cwd: "/repo" } as const; + +function qwenIntegration(overrides?: { executable?: string | undefined; authenticated?: boolean }) { + return createQwenAgentIntegration({ + resolveExecutable: (command) => { + if (command === "npm") { + return "/usr/local/bin/npm"; + } + return overrides !== undefined && "executable" in overrides + ? overrides.executable + : "/opt/homebrew/bin/qwen"; + }, + readProviderState: () => ({ authenticated: overrides?.authenticated ?? true }), + defaultCwd: "/repo", + tideMcp: { command: "/Applications/Tide.app/tide-mcp", args: ["mcp"], env: { TIDE_SOCKET: "/tmp/tide.sock" } }, + }); +} + +test("qwenConfigOptions maps explicit model and skips the Qwen default sentinel", () => { + assert.deepEqual( + qwenConfigOptions({ model: "qwen3-coder-plus" }, ["model"]), + [{ configId: "model", value: "qwen3-coder-plus" }], + ); + assert.deepEqual(qwenConfigOptions({ model: "Qwen default" }, ["model"]), []); + assert.deepEqual(qwenConfigOptions({ model: "qwen3-coder-plus" }, ["permission"]), []); +}); + +test("qwenProtocolParams carries ACP config options and modeId together", () => { + assert.deepEqual( + qwenProtocolParams( + { model: "qwen3-coder-plus", permission: "plan" }, + ["model", "permission"], + ), + { + configOptions: [{ configId: "model", value: "qwen3-coder-plus" }], + modeId: "plan", + }, + ); +}); + +test("qwen preflight reports install and auth blockers with provider-native terminal actions", async () => { + const missing = await qwenIntegration({ executable: undefined }).preflight({ + agentId: "qwen", + scope: projectScope, + }); + assert.equal(missing.ready, false); + assert.equal(missing.blockers[0]?.kind, "not_installed"); + assert.equal(missing.blockers[0]?.terminalAction?.command, "/usr/local/bin/npm"); + assert.deepEqual(missing.blockers[0]?.terminalAction?.args, [ + "install", + "-g", + "@qwen-code/qwen-code", + ]); + assert.equal(missing.blockers[0]?.terminalAction?.expectedCompletion, "retry_preflight"); + + const signedOut = await qwenIntegration({ authenticated: false }).preflight({ + agentId: "qwen", + scope: projectScope, + }); + assert.equal(signedOut.ready, false); + assert.equal(signedOut.blockers[0]?.kind, "not_authenticated"); + assert.equal(signedOut.blockers[0]?.terminalAction?.command, "/opt/homebrew/bin/qwen"); + assert.deepEqual(signedOut.blockers[0]?.terminalAction?.args, []); + assert.match(signedOut.blockers[0]?.message ?? "", /\/auth/); +}); + +test("qwen start plan launches ACP stdio with Tide MCP and launch config", async () => { + const plan = await qwenIntegration().buildStartPlan({ + agentId: "qwen", + scope: projectScope, + launchOptions: { model: "qwen3-coder-plus", permission: "yolo" }, + }); + + assert.equal(plan.command, "/opt/homebrew/bin/qwen"); + assert.deepEqual(plan.args, ["--acp"]); + assert.equal(plan.transport, "acp"); + assert.equal(plan.cwd, "/repo"); + assert.deepEqual(plan.env, {}); + assert.deepEqual(plan.protocolParams, { + cwd: "/repo", + configOptions: [{ configId: "model", value: "qwen3-coder-plus" }], + modeId: "yolo", + mcpServers: [ + { + name: "tide", + command: "/Applications/Tide.app/tide-mcp", + args: ["mcp"], + env: [{ name: "TIDE_SOCKET", value: "/tmp/tide.sock" }], + }, + ], + }); +}); + +test("qwen provider-state reader accepts env, settings env, and cwd dotenv credentials", () => { + const home = fs.mkdtempSync(path.join(tmpdir(), "tide-qwen-state-home-")); + const cwd = path.join(home, "work", "repo", "nested"); + fs.mkdirSync(cwd, { recursive: true }); + + assert.equal(readQwenProviderStateFromHome(home, cwd, {}).authenticated, false); + assert.equal( + readQwenProviderStateFromHome(home, cwd, { GITHUB_TOKEN: "ghp-not-qwen" }).authenticated, + false, + ); + assert.equal( + readQwenProviderStateFromHome(home, cwd, { DASHSCOPE_API_KEY: "sk-real" }).authenticated, + true, + ); + + writeFile( + path.join(home, ".qwen", "settings.json"), + JSON.stringify({ env: { OPENAI_API_KEY: "sk-settings" } }), + ); + assert.equal(readQwenProviderStateFromHome(home, cwd, {}).authenticated, true); + + const dotenvHome = fs.mkdtempSync(path.join(tmpdir(), "tide-qwen-dotenv-home-")); + const dotenvCwd = path.join(dotenvHome, "project", "src"); + writeFile(path.join(dotenvHome, "project", ".qwen", ".env"), "DASHSCOPE_API_KEY=sk-dotenv\n"); + assert.equal(readQwenProviderStateFromHome(dotenvHome, dotenvCwd, {}).authenticated, true); + + const shadowHome = fs.mkdtempSync(path.join(tmpdir(), "tide-qwen-shadow-env-home-")); + const shadowCwd = path.join(shadowHome, "project", "src"); + writeFile(path.join(shadowHome, "project", ".qwen", ".env"), "GITHUB_TOKEN=ghp-not-qwen\n"); + writeFile(path.join(shadowHome, ".qwen", ".env"), "DASHSCOPE_API_KEY=sk-home\n"); + assert.equal(readQwenProviderStateFromHome(shadowHome, shadowCwd, {}).authenticated, false); + + const customHome = fs.mkdtempSync(path.join(tmpdir(), "tide-qwen-custom-env-home-")); + const customCwd = path.join(customHome, "project"); + writeFile( + path.join(customHome, ".qwen", "settings.json"), + JSON.stringify({ + modelProviders: { + openai: { + models: [{ id: "local-qwen", envKey: "CUSTOM_QWEN_KEY" }], + }, + }, + }), + ); + assert.equal( + readQwenProviderStateFromHome(customHome, customCwd, { CUSTOM_QWEN_KEY: "sk-custom" }).authenticated, + true, + ); +}); + +function writeFile(filePath: string, contents: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} diff --git a/apps/desktop/tests/start-composer-preference-persistence.test.ts b/apps/desktop/tests/start-composer-preference-persistence.test.ts index eeb6e7cc..45ff735e 100644 --- a/apps/desktop/tests/start-composer-preference-persistence.test.ts +++ b/apps/desktop/tests/start-composer-preference-persistence.test.ts @@ -53,6 +53,24 @@ test("an opencode Start Composer preference round-trips through storage", () => }); }); +test("a qwen Start Composer preference round-trips through storage", () => { + clearPrefs(); + persistPreferredStartComposer({ + agentId: "qwen", + model: "Qwen default", + permission: "default", + reasoning: "high", + worktree: "reuse", + }); + assert.deepEqual(loadPreferredStartComposer(), { + agentId: "qwen", + model: "Qwen default", + permission: "default", + reasoning: "high", + worktree: "reuse", + }); +}); + test("an unknown persisted agent loads as null (no preference)", () => { clearPrefs(); // A stored record for an agent the build no longer knows must load as null. @@ -61,7 +79,7 @@ test("an unknown persisted agent loads as null (no preference)", () => { }); test("isProductShellAgentIdentity accepts the provider CLI agents, rejects undefined/unknown", () => { - for (const id of ["codex", "claude", "opencode"]) { + for (const id of ["codex", "claude", "opencode", "qwen"]) { assert.equal(isProductShellAgentIdentity(id), true, `expected ${id} to be a valid agent`); } assert.equal(isProductShellAgentIdentity(undefined), false);