Skip to content
Draft
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
4 changes: 2 additions & 2 deletions apps/desktop/scripts/v2-electron-runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/scripts/v2-launch-change-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
// 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
// side effect (a written file) MUST exist.
//
// Usage:
// node --experimental-strip-types scripts/v2-launch-change-flow.mjs \
// --agent <claude|codex|opencode> [--option permission|model|reasoning]
// --agent <claude|codex|opencode|qwen> [--option permission|model|reasoning]
// [--behavioral] [--timeout-ms 120000]
//
// Run with TIDE_BACKEND_TRACE=1 to also see the `applySessionConfig <agent> keys=…`
Expand Down Expand Up @@ -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 <claude|codex|opencode> [--option permission|model|reasoning] [--behavioral]");
if (!["claude", "codex", "opencode", "qwen"].includes(agent)) {
console.error("Usage: --agent <claude|codex|opencode|qwen> [--option permission|model|reasoning] [--behavioral]");
process.exit(2);
}

Expand All @@ -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}-`));
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/scripts/v2-provider-permission-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <claude|codex|opencode>
// Usage: node scripts/v2-provider-permission-flow.mjs --agent <claude|codex|opencode|qwen>
// [--deny] [--timeout-ms 240000]

import { mkdtempSync } from "node:fs";
Expand Down Expand Up @@ -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 <claude|codex|opencode>");
console.error("Usage: node scripts/v2-provider-permission-flow.mjs --agent <claude|codex|opencode|qwen>");
process.exit(2);
}
const message = process.env.TIDE_MESSAGE ?? scenario.message;
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/scripts/v2-provider-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -285,6 +285,8 @@ function labelForAgent(agent) {
return "Claude Code";
case "opencode":
return "opencode";
case "qwen":
return "Qwen Code";
default:
return agent;
}
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/scripts/v2-provider-state-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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?"),
);
Expand Down Expand Up @@ -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?"),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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> | string | undefined;

export type QwenProviderStateReader = (input: {
cwd: string;
executablePath: string;
}) => Promise<QwenProviderState> | QwenProviderState;

export interface QwenTideMcpConfig {
command: string;
args: string[];
env?: Record<string, string>;
}

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<AgentIntegrationPreflightResult> {
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<ProviderLaunchPlan> {
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<ProviderLaunchPlan> {
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<string, unknown>;
}): 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<string, unknown> | undefined,
changedKeys: ReadonlyArray<string>,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
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<string, unknown> | undefined,
changedKeys: ReadonlyArray<string>,
): 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 }];
}
Loading
Loading