Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/supervisor/agents/qwen/detection.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
43 changes: 37 additions & 6 deletions src/supervisor/agents/qwen/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, string | undefined>();
const detectionGenerations = new Map<string, number>();
let nextDetectionGeneration = 0;

return {
kind: qwenDetectionSpec.kind,
Expand Down Expand Up @@ -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) {
Expand All @@ -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, {
Expand Down
218 changes: 217 additions & 1 deletion src/supervisor/agents/qwen/qwen.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../base")>()),
detectAgentInstall: detectAgentInstallMock,
}));

vi.mock("../acp", async (importOriginal) => ({
...(await importOriginal<typeof import("../acp")>()),
createAcpStructuredSession: vi.fn<() => undefined>(() => undefined),
}));

vi.mock("../binaryResolver", () => ({ resolveAgentBinaryPath: resolveAgentBinaryPathMock }));

afterEach(() => {
vi.unstubAllEnvs();
detectAgentInstallMock.mockReset();
resolveAgentBinaryPathMock.mockClear();
vi.mocked(createAcpStructuredSession).mockClear();
});

describe("buildQwenArgs", () => {
Expand Down Expand Up @@ -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<void>((resolve) => {
releaseNative = resolve;
});
const wslReady = new Promise<void>((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<never>((_, 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<void>((resolve) => {
resolveOlder = resolve;
});
const newerReady = new Promise<void>((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<never>((_, reject) => {
rejectOlder = reject;
});
const newerReady = new Promise<void>((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({
Expand Down