From 18f573934ab1a60d195665a64287f85bb9bb72ea Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 24 Aug 2026 02:29:35 -0700 Subject: [PATCH] fix(qwen): gate ACP restore flag by detected CLI version - Track version detection per environment and generation - Add coverage for version gating and stale detection results --- src/supervisor/agents/qwen/detection.ts | 12 ++ src/supervisor/agents/qwen/index.ts | 43 ++++- src/supervisor/agents/qwen/qwen.test.ts | 218 +++++++++++++++++++++++- 3 files changed, 266 insertions(+), 7 deletions(-) diff --git a/src/supervisor/agents/qwen/detection.ts b/src/supervisor/agents/qwen/detection.ts index d85dbcbfd..64ec87c4a 100644 --- a/src/supervisor/agents/qwen/detection.ts +++ b/src/supervisor/agents/qwen/detection.ts @@ -1,5 +1,6 @@ import type { AgentCapability, AgentTerminalAuthMethod, ProjectLocation } from "@/shared/contracts"; import { QWEN_RETIRED_PREVIEW_MODEL_ID } from "@/shared/agents/qwenModels"; +import { compareVersions } from "@/shared/changelog"; import { humanizeModelId, probeAcpCapabilities, type AcpProbeResult } from "../acp"; import { buildAgentCommand, @@ -43,6 +44,17 @@ export function buildQwenCommand( return buildAgentCommand(location, "qwen", args, executablePath); } +// Qwen Code 0.22.0 re-hangs a trailing unanswered ask_user_question on ACP +// load/resume instead of synthesizing a failed tool result. Older CLIs parse +// with strict yargs and exit on unknown flags, so gate on the detected version. +const RESTORE_ASK_USER_QUESTION_MIN_VERSION = "0.22.0"; + +export function buildQwenAcpSessionArgs(version: string | undefined): string[] { + const supportsRestore = + version !== undefined && compareVersions(version, RESTORE_ASK_USER_QUESTION_MIN_VERSION) >= 0; + return supportsRestore ? ["--acp", "--restore-ask-user-question"] : ["--acp"]; +} + const terminalAuthMethod: AgentTerminalAuthMethod = { id: "qwen-terminal-login", name: "Login", diff --git a/src/supervisor/agents/qwen/index.ts b/src/supervisor/agents/qwen/index.ts index c5c81c3a1..300de6974 100644 --- a/src/supervisor/agents/qwen/index.ts +++ b/src/supervisor/agents/qwen/index.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { PromptSegment } from "@/shared/contracts"; +import type { ProjectLocation, PromptSegment } from "@/shared/contracts"; import { inlinePromptSegmentText } from "@/shared/promptContent"; import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor"; import { createAcpStructuredSession } from "../acp"; @@ -15,13 +15,30 @@ import { import { resolveAgentBinaryPath } from "../binaryResolver"; import { buildQwenArgs, QWEN_DEFAULT_MODEL_ID } from "./argv"; import { createQwenAcpSessionBridge } from "./acpTransform"; -import { buildQwenCommand, qwenDefaultCapabilities, qwenDetectionSpec } from "./detection"; +import { + buildQwenAcpSessionArgs, + buildQwenCommand, + qwenDefaultCapabilities, + qwenDetectionSpec, +} from "./detection"; import { detectQwenInvalidSessionRef } from "./session"; export { detectQwenInvalidSessionRef } from "./session"; +function qwenEnvironmentKey(location: ProjectLocation): string { + return location.kind === "wsl" ? `wsl:${location.distro}` : location.kind; +} + +function qwenDetectionEnvironmentKey(ctx: AgentEnvContext | undefined): string { + if (ctx?.envKind === "wsl") return `wsl:${ctx.wslDistro ?? ""}`; + return ctx?.envKind ?? (process.platform === "win32" ? "windows" : "posix"); +} + export function createQwenAdapter(): AgentAdapter { let capabilities = qwenDefaultCapabilities; + const detectedVersions = new Map(); + const detectionGenerations = new Map(); + let nextDetectionGeneration = 0; return { kind: qwenDetectionSpec.kind, @@ -56,9 +73,23 @@ export function createQwenAdapter(): AgentAdapter { spawnEnv: { wsl: { BROWSER: "/bin/true" } }, async detectInstall(ctx) { - const status = await detectAgentInstall(ctx, qwenDetectionSpec); - capabilities = status.capabilities; - return status; + const environmentKey = qwenDetectionEnvironmentKey(ctx); + const detectionGeneration = ++nextDetectionGeneration; + detectionGenerations.set(environmentKey, detectionGeneration); + detectedVersions.delete(environmentKey); + try { + const status = await detectAgentInstall(ctx, qwenDetectionSpec); + capabilities = status.capabilities; + if (detectionGenerations.get(environmentKey) === detectionGeneration) { + detectedVersions.set(environmentKey, status.version); + } + return status; + } catch (error) { + if (detectionGenerations.get(environmentKey) === detectionGeneration) { + detectedVersions.delete(environmentKey); + } + throw error; + } }, buildLaunchArgv(_location, config, prompt) { @@ -81,7 +112,7 @@ export function createQwenAdapter(): AgentAdapter { const acpBridge = createQwenAcpSessionBridge(); const command = buildQwenCommand( input.projectLocation, - ["--acp"], + buildQwenAcpSessionArgs(detectedVersions.get(qwenEnvironmentKey(input.projectLocation))), resolveAgentBinaryPath(input.projectLocation, "qwen"), ); return createAcpStructuredSession(command, { diff --git a/src/supervisor/agents/qwen/qwen.test.ts b/src/supervisor/agents/qwen/qwen.test.ts index 0fb33f4c9..e3dba3609 100644 --- a/src/supervisor/agents/qwen/qwen.test.ts +++ b/src/supervisor/agents/qwen/qwen.test.ts @@ -1,14 +1,46 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; +import { createAcpStructuredSession } from "../acp"; +import type { CreateStructuredSessionInput } from "../base"; import { createQwenAdapter } from "."; import { buildQwenArgs, QWEN_DEFAULT_MODEL_ID } from "./argv"; -import { buildQwenProbeCapabilities, QWEN_AUTH_ENV_KEYS, qwenDetectionSpec } from "./detection"; +import { + buildQwenAcpSessionArgs, + buildQwenProbeCapabilities, + QWEN_AUTH_ENV_KEYS, + qwenDefaultCapabilities, + qwenDetectionSpec, +} from "./detection"; import { detectQwenInvalidSessionRef } from "./session"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; +const detectAgentInstallMock = vi.hoisted(() => + vi.fn<(...args: unknown[]) => Promise<{ version?: string; capabilities?: unknown }>>(), +); +const resolveAgentBinaryPathMock = vi.hoisted(() => + vi.fn<(location: ProjectLocation) => string | undefined>((location) => + location.kind === "windows" ? "C:\\tools\\qwen.exe" : undefined, + ), +); + +vi.mock("../base", async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentInstall: detectAgentInstallMock, +})); + +vi.mock("../acp", async (importOriginal) => ({ + ...(await importOriginal()), + createAcpStructuredSession: vi.fn<() => undefined>(() => undefined), +})); + +vi.mock("../binaryResolver", () => ({ resolveAgentBinaryPath: resolveAgentBinaryPathMock })); + afterEach(() => { vi.unstubAllEnvs(); + detectAgentInstallMock.mockReset(); + resolveAgentBinaryPathMock.mockClear(); + vi.mocked(createAcpStructuredSession).mockClear(); }); describe("buildQwenArgs", () => { @@ -94,6 +126,190 @@ describe("createQwenAdapter", () => { }); }); +describe("buildQwenAcpSessionArgs", () => { + it("enables ask_user_question restore on Qwen 0.22.0 and newer", () => { + expect(buildQwenAcpSessionArgs("0.22.0")).toEqual(["--acp", "--restore-ask-user-question"]); + expect(buildQwenAcpSessionArgs("0.23.1")).toEqual(["--acp", "--restore-ask-user-question"]); + expect(buildQwenAcpSessionArgs("v0.22.0")).toEqual(["--acp", "--restore-ask-user-question"]); + }); + + it("keeps plain --acp for older or undetected CLIs", () => { + expect(buildQwenAcpSessionArgs("0.21.15")).toEqual(["--acp"]); + expect(buildQwenAcpSessionArgs("0.21.14-nightly.20260822")).toEqual(["--acp"]); + expect(buildQwenAcpSessionArgs(undefined)).toEqual(["--acp"]); + }); +}); + +describe("Qwen ACP session spawn", () => { + const sessionInput: CreateStructuredSessionInput = { + threadId: "thread-1", + projectLocation: { kind: "windows", path: "C:\\repo" }, + config: { model: QWEN_DEFAULT_MODEL_ID }, + }; + + it("passes --restore-ask-user-question once Qwen 0.22.0 is detected", async () => { + detectAgentInstallMock.mockResolvedValue({ + version: "0.22.0", + capabilities: qwenDefaultCapabilities, + }); + const adapter = createQwenAdapter(); + await adapter.detectInstall({ envKind: "windows" }); + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-2)).toEqual(["--acp", "--restore-ask-user-question"]); + }); + + it("spawns plain --acp while the detected CLI is older", async () => { + detectAgentInstallMock.mockResolvedValue({ + version: "0.21.15", + capabilities: qwenDefaultCapabilities, + }); + const adapter = createQwenAdapter(); + await adapter.detectInstall({ envKind: "windows" }); + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-1)).toEqual(["--acp"]); + }); + + it("spawns plain --acp before any detection has run", async () => { + const adapter = createQwenAdapter(); + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-1)).toEqual(["--acp"]); + }); + + it("uses the detected version for the matching native or WSL environment", async () => { + let releaseNative!: () => void; + let releaseWsl!: () => void; + const nativeReady = new Promise((resolve) => { + releaseNative = resolve; + }); + const wslReady = new Promise((resolve) => { + releaseWsl = resolve; + }); + detectAgentInstallMock.mockImplementation(async (ctx: unknown) => { + if ((ctx as { envKind?: string }).envKind === "wsl") { + await wslReady; + return { version: "0.21.15", capabilities: qwenDefaultCapabilities }; + } + await nativeReady; + return { version: "0.22.0", capabilities: qwenDefaultCapabilities }; + }); + + const adapter = createQwenAdapter(); + const nativeDetection = adapter.detectInstall({ envKind: "windows" }); + const wslDetection = adapter.detectInstall({ envKind: "wsl", wslDistro: "Ubuntu" }); + releaseWsl(); + releaseNative(); + await Promise.all([nativeDetection, wslDetection]); + + await adapter.createStructuredSession?.(sessionInput); + await adapter.createStructuredSession?.({ + ...sessionInput, + projectLocation: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\repo", + }, + }); + + const commands = vi.mocked(createAcpStructuredSession).mock.calls.map(([command]) => command); + expect(commands[0]?.args.slice(-2)).toEqual(["--acp", "--restore-ask-user-question"]); + expect(commands[1]?.args.join(" ")).toContain("--acp"); + expect(commands[1]?.args.join(" ")).not.toContain("restore-ask-user-question"); + }); + + it("clears a stale version after detection fails", async () => { + const adapter = createQwenAdapter(); + detectAgentInstallMock.mockResolvedValue({ + version: "0.22.0", + capabilities: qwenDefaultCapabilities, + }); + await adapter.detectInstall({ envKind: "windows" }); + + let rejectDetection!: (error: Error) => void; + detectAgentInstallMock.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectDetection = reject; + }), + ); + const detection = adapter.detectInstall({ envKind: "windows" }); + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-1)).toEqual(["--acp"]); + rejectDetection(new Error("probe failed")); + await expect(detection).rejects.toThrow("probe failed"); + }); + + it("keeps the newest result when overlapping detections finish out of order", async () => { + let resolveOlder!: () => void; + let resolveNewer!: () => void; + const olderReady = new Promise((resolve) => { + resolveOlder = resolve; + }); + const newerReady = new Promise((resolve) => { + resolveNewer = resolve; + }); + let callCount = 0; + detectAgentInstallMock.mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + await olderReady; + return { version: "0.21.15", capabilities: qwenDefaultCapabilities }; + } + await newerReady; + return { version: "0.22.0", capabilities: qwenDefaultCapabilities }; + }); + + const adapter = createQwenAdapter(); + const olderDetection = adapter.detectInstall({ envKind: "windows" }); + const newerDetection = adapter.detectInstall({ envKind: "windows" }); + resolveNewer(); + resolveOlder(); + await Promise.all([olderDetection, newerDetection]); + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-2)).toEqual(["--acp", "--restore-ask-user-question"]); + }); + + it("does not clear a newer result when an older detection fails", async () => { + let rejectOlder!: (error: Error) => void; + let resolveNewer!: () => void; + const olderReady = new Promise((_, reject) => { + rejectOlder = reject; + }); + const newerReady = new Promise((resolve) => { + resolveNewer = resolve; + }); + let callCount = 0; + detectAgentInstallMock.mockImplementation(async () => { + callCount += 1; + if (callCount === 1) return olderReady; + await newerReady; + return { version: "0.22.0", capabilities: qwenDefaultCapabilities }; + }); + + const adapter = createQwenAdapter(); + const olderDetection = adapter.detectInstall({ envKind: "windows" }); + const newerDetection = adapter.detectInstall({ envKind: "windows" }); + resolveNewer(); + rejectOlder(new Error("probe failed")); + await expect(olderDetection).rejects.toThrow("probe failed"); + await newerDetection; + await adapter.createStructuredSession?.(sessionInput); + + const command = vi.mocked(createAcpStructuredSession).mock.calls[0]?.[0]; + expect(command?.args.slice(-2)).toEqual(["--acp", "--restore-ask-user-question"]); + }); +}); + describe("buildQwenProbeCapabilities", () => { it("maps ACP models, context limits, and auth state", () => { const capabilities = buildQwenProbeCapabilities({