Skip to content
Open
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
236 changes: 236 additions & 0 deletions app/native-server/src/agent/engines/qwen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { join } from 'node:path';
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imported join function from 'node:path' is never used in this file. Consider removing this unused import to keep the code clean.

Suggested change
import { join } from 'node:path';

Copilot uses AI. Check for mistakes.
import { mkdir } from 'node:fs/promises';
import type { AgentEngine, EngineExecutionContext, EngineName, EngineInitOptions } from './types';
import type { RealtimeEvent, AgentMessage } from '../types';
Comment on lines +1 to +6
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unused imports here (join from node:path and RealtimeEvent), and the EventEmitter base class doesn't appear to be used. Consider removing the unused imports/inheritance to keep the engine minimal and avoid confusion about an event-emitter API that isn't actually exposed.

Copilot uses AI. Check for mistakes.
import { randomUUID } from 'node:crypto';

/**
* QwenEngine integrates the Qwen Code CLI as an AgentEngine implementation.
*
* Uses `qwen -y -p "<instruction>"` for non-interactive agent mode.
*/
export class QwenEngine extends EventEmitter implements AgentEngine {
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The QwenEngine extends EventEmitter but never emits any events through the EventEmitter interface. All events are emitted through the ctx.emit() method from EngineExecutionContext. This inheritance appears unnecessary and could be removed to simplify the class structure. Other engines like ClaudeEngine and CodexEngine do not extend EventEmitter.

Copilot uses AI. Check for mistakes.
readonly name: EngineName = 'qwen';
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The QwenEngine class does not define the supportsMcp property. According to the AgentEngine interface, this is an optional property, but it should be explicitly set to false to indicate that this engine does not support MCP natively (as documented in the PR description). This is consistent with CodexEngine which explicitly sets supportsMcp = false, while ClaudeEngine sets it to true.

Suggested change
readonly name: EngineName = 'qwen';
readonly name: EngineName = 'qwen';
readonly supportsMcp = false;

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name property is typed as EngineName but should be explicitly typed as 'qwen' as const for consistency with other engines. ClaudeEngine uses public readonly name = 'claude' as const and CodexEngine uses public readonly name = 'codex' as const. The current typing doesn't provide the same level of type safety and literal type inference.

Copilot uses AI. Check for mistakes.
private abortController: AbortController | null = null;

async initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void> {
const trimmed = options.instruction.trim();
if (!trimmed) {
throw new Error('QwenEngine: instruction must not be empty');
}

if (options.signal?.aborted) {
throw new Error('QwenEngine: execution was cancelled');
}

this.abortController = new AbortController();
const { signal } = this.abortController;

Comment on lines +28 to +30
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancellation is wired to a new internal AbortController, but AgentChatService passes cancellation via options.signal. As a result, UI-driven cancellation (abortController.abort() in chat-service) won't terminate the spawned qwen process. Use options.signal for spawn/abort handling (or forward options.signal to the internal controller) instead of creating a separate controller here.

Suggested change
this.abortController = new AbortController();
const { signal } = this.abortController;
let signal: AbortSignal;
if (options.signal) {
// Use the caller-provided signal so UI-driven cancellation propagates to the spawned process.
const externalSignal = options.signal;
signal = externalSignal;
// Maintain an internal controller for the engine's cancel() API while mirroring external aborts.
this.abortController = new AbortController();
const internalController = this.abortController;
const onAbort = () => {
internalController.abort(externalSignal.reason);
externalSignal.removeEventListener('abort', onAbort);
};
if (externalSignal.aborted) {
onAbort();
} else {
externalSignal.addEventListener('abort', onAbort);
}
} else {
this.abortController = new AbortController();
signal = this.abortController.signal;
}

Copilot uses AI. Check for mistakes.
const repoPath = options.projectRoot || process.cwd();
const resolvedModel = options.model || '';

console.error(`[QwenEngine] Starting query with model: ${resolvedModel || 'default'}`);
console.error(`[QwenEngine] Working directory: ${repoPath}`);

// Build command arguments
const args: string[] = ['-y']; // YOLO mode - auto-approve all actions

if (resolvedModel) {
args.push('-m', resolvedModel);
}

args.push('-p', trimmed);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instruction is directly passed to the command-line via the -p flag without proper escaping or validation beyond trimming. While spawn() with an array of arguments is safer than shell execution, the qwen CLI itself might interpret special characters or sequences in unexpected ways. Consider adding input validation to detect and reject potentially problematic characters, or documenting the security assumptions about the qwen CLI's input handling.

Copilot uses AI. Check for mistakes.

// Ensure project directory exists
await mkdir(repoPath, { recursive: true });
Comment on lines +46 to +47
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating repoPath with mkdir(..., { recursive: true }) can mask configuration errors (e.g., a mistyped/nonexistent project root) by silently creating an empty directory and running qwen there. Other engines assume the project root exists. Consider validating the directory exists (and erroring with a clear message) instead of creating it.

Copilot uses AI. Check for mistakes.

return new Promise<void>((resolve, reject) => {
const qwen = spawn('qwen', args, {
cwd: repoPath,
env: { ...process.env },
signal: signal as any,
});

Comment on lines +53 to +55
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation creates an internal AbortController but passes the external signal directly to the spawn() call. This differs from how other engines handle signals. Both ClaudeEngine and CodexEngine use signal.addEventListener('abort', ...) to manually handle cancellation, without passing the signal to the spawn options. The current approach may cause the internal abortController to become out of sync with the process state, as the process might be terminated by Node.js before the internal controller is notified. Consider adopting the pattern used in CodexEngine where the signal is only listened to via addEventListener, not passed to spawn.

Suggested change
signal: signal as any,
});
});
const handleAbort = () => {
if (!qwen.killed) {
qwen.kill('SIGTERM');
}
};
// Wire engine-level cancellation (this.abortController) to the spawned process
const internalSignal = this.abortController?.signal;
internalSignal?.addEventListener('abort', handleAbort);
// Wire external cancellation (options.signal) to the spawned process
const externalSignal = options.signal;
const externalAbortHandler =
externalSignal && !externalSignal.aborted ? handleAbort : null;
if (externalAbortHandler && externalSignal) {
externalSignal.addEventListener('abort', externalAbortHandler);
} else if (externalSignal?.aborted) {
// If already aborted, ensure the process is terminated promptly
handleAbort();
}
// Ensure we clean up abort listeners once the process exits
qwen.once('close', () => {
internalSignal?.removeEventListener('abort', handleAbort);
if (externalAbortHandler && externalSignal) {
externalSignal.removeEventListener('abort', externalAbortHandler);
}
});

Copilot uses AI. Check for mistakes.
let stdoutBuffer = '';
const stderrBuffer: string[] = [];
const MAX_STDERR_LINES = 100;
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation lacks a static MAX_STDERR_LINES constant like the other engines. While there is a local MAX_STDERR_LINES variable (line 58), it should be defined as a static readonly class member for consistency with ClaudeEngine and CodexEngine, which both define it as private static readonly MAX_STDERR_LINES = 200.

Copilot uses AI. Check for mistakes.

// Handle stdout - parse for events
stdoutBuffer = '';
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable stdoutBuffer is initialized twice - once on line 56 and again on line 61. The first initialization is redundant and should be removed.

Suggested change
stdoutBuffer = '';

Copilot uses AI. Check for mistakes.
qwen.stdout.on('data', (data: Buffer) => {
const text = data.toString();
stdoutBuffer += text;

// Try to parse events from output
const lines = text.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;

// Detect tool calls
if (trimmedLine.includes('Calling tool') || trimmedLine.includes('Running')) {
const toolName = extractToolName(trimmedLine);
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'running',
message: `Using tool: ${toolName}`,
requestId: options.requestId,
},
});
}

// Detect thinking/processing - emit as assistant message
if (trimmedLine.startsWith('Qwen') || trimmedLine.includes('Thinking')) {
const message: AgentMessage = {
id: randomUUID(),
sessionId: options.sessionId,
role: 'assistant',
content: trimmedLine,
messageType: 'chat',
createdAt: new Date().toISOString(),
};
ctx.emit({
type: 'message',
data: message,
});
Comment on lines +88 to +99
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engine-emitted assistant messages here omit fields that other engines include (requestId and cliSource). AgentChatService persists events using these fields, and the UI may rely on them for correlation/filtering. Consider setting cliSource: this.name and requestId: options.requestId on all emitted AgentMessage objects.

Copilot uses AI. Check for mistakes.
}
Comment on lines +86 to +100
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output parsing approach is fragile and may capture noise. The condition on line 87 (trimmedLine.startsWith('Qwen') || trimmedLine.includes('Thinking')) will match any line containing the word "Thinking" anywhere, which could result in false positives. For example, a file path or error message containing "Thinking" would be emitted as an assistant message. Consider making the pattern matching more specific, such as checking for exact prefixes or using more structured patterns.

Copilot uses AI. Check for mistakes.
}
});

// Handle stderr - log and emit as status
qwen.stderr.on('data', (data: Buffer) => {
const line = data.toString().trim();
if (!line) return;

stderrBuffer.push(line);
if (stderrBuffer.length > MAX_STDERR_LINES) {
stderrBuffer.splice(0, stderrBuffer.length - MAX_STDERR_LINES);
}

console.error(`[QwenEngine][stderr] ${line}`);

// Emit as status for visibility
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'running',
message: line,
requestId: options.requestId,
},
});
});
Comment on lines +116 to +126
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All stderr output is emitted as status events with status: 'running'. This could be problematic because stderr often contains actual error messages, warnings, or diagnostic information that should be handled differently. Error messages would be presented to the user as "running" status, which is confusing. Consider differentiating between informational stderr output and actual errors, or only emit stderr as status for informational messages while logging errors separately.

Copilot uses AI. Check for mistakes.

// Handle process exit
qwen.on('close', (code) => {
console.error(`[QwenEngine] Process exited with code ${code}`);

if (code === 0) {
// Emit final output as assistant message
if (stdoutBuffer.trim()) {
const message: AgentMessage = {
id: randomUUID(),
sessionId: options.sessionId,
role: 'assistant',
content: stdoutBuffer,
messageType: 'chat',
isFinal: true,
createdAt: new Date().toISOString(),
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final assistant message also omits requestId/cliSource (and will likely duplicate content already emitted from per-line parsing). Consider emitting either incremental messages with isStreaming or a single final message, and include the standard correlation fields (requestId, cliSource) to match the other engines' message shape.

Suggested change
createdAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
requestId: options.requestId,
cliSource: 'qwen',

Copilot uses AI. Check for mistakes.
};
ctx.emit({
type: 'message',
data: message,
});
}

Comment on lines +133 to +149
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On successful completion, the entire stdoutBuffer is emitted as a single final message. This means the user would see all the accumulated output at once, including previously emitted line-by-line messages on lines 88-100. This creates duplicate content in the chat UI. Consider either: (1) only emitting the full buffer as the final message and removing the line-by-line emission, or (2) tracking what has already been emitted and only include new content in the final message.

Suggested change
// Emit final output as assistant message
if (stdoutBuffer.trim()) {
const message: AgentMessage = {
id: randomUUID(),
sessionId: options.sessionId,
role: 'assistant',
content: stdoutBuffer,
messageType: 'chat',
isFinal: true,
createdAt: new Date().toISOString(),
};
ctx.emit({
type: 'message',
data: message,
});
}

Copilot uses AI. Check for mistakes.
// Emit completion status
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'completed',
requestId: options.requestId,
},
});

Comment on lines +150 to +159
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This engine emits its own 'completed' status event, but AgentChatService already emits 'completed' after initializeAndRun resolves. This will cause duplicate completion events in the UI/stream. Consider removing this status emission and letting the chat-service handle lifecycle statuses consistently across engines.

Suggested change
// Emit completion status
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'completed',
requestId: options.requestId,
},
});

Copilot uses AI. Check for mistakes.
resolve();
} else if (code === null || code === 130) {
console.error('[QwenEngine] Execution cancelled via abort signal');
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'cancelled',
requestId: options.requestId,
},
});
reject(new Error('QwenEngine: execution was cancelled'));
} else {
const stderrOutput = stderrBuffer.join('\n');
const errorMessage = `QwenEngine: process terminated with code ${code}\n${stderrOutput}`;
ctx.emit({
type: 'status',
data: {
sessionId: options.sessionId,
status: 'error',
message: errorMessage,
requestId: options.requestId,
},
});
reject(new Error(errorMessage));
}
});

// Handle process errors
qwen.on('error', (err) => {
console.error('[QwenEngine] Process error:', err);
const errorMessage = `QwenEngine: failed to start - ${err.message}`;
ctx.emit({
type: 'error',
error: errorMessage,
data: {
sessionId: options.sessionId,
requestId: options.requestId,
},
});
reject(new Error(errorMessage));
Comment on lines +161 to +200
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to 'completed', this engine emits 'error'/'cancelled' status (and also emits a top-level 'error' event on spawn failure) while AgentChatService already normalizes errors/cancellation into stream events. This can lead to duplicated/conflicting error/cancel events. Prefer throwing/rejecting and letting AgentChatService publish the canonical error/status events (keep only tool/progress messages if needed).

Copilot uses AI. Check for mistakes.
});
Comment on lines +128 to +201
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Promise can settle multiple times: qwen.on('error', ...) calls reject, but qwen.on('close', ...) will still run and may resolve/reject again, and you may emit multiple status events. Consider adding a settled flag (like CodexEngine does) and performing one centralized cleanup/settlement path.

Copilot uses AI. Check for mistakes.

// Handle abort signal
signal?.addEventListener('abort', () => {
console.error('[QwenEngine] Abort signal received');
try {
qwen.kill('SIGTERM');
} catch (e) {
// Ignore if already killed
}
});
Comment on lines +203 to +211
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal handling logic adds an event listener but there's a potential issue: if the external options.signal is already aborted (checked on line 24), the listener registered on line 204 will never fire because spawn() with the signal will fail immediately. However, there's still a race condition - the signal could be aborted between line 24 and line 50. In this case, the internal abortController would never be notified. Consider removing the signal from spawn options and relying solely on the addEventListener pattern, or ensure the internal abortController is aborted when the signal is already aborted.

Copilot uses AI. Check for mistakes.
});
}

cancel(): void {
this.abortController?.abort();
}
Comment on lines +215 to +217
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancel() method only calls this.abortController?.abort() but the internal abortController is not connected to the external signal from options. The pattern used here differs from other engines: ClaudeEngine and CodexEngine don't implement a cancel() method because they rely on the external signal passed via options. Since the AbortController interface is not part of the AgentEngine interface, this method won't be called by the chat service. The chat service uses the AbortController it creates and passes via options.signal. This cancel() method is effectively dead code.

Copilot uses AI. Check for mistakes.
}

/**
* Extract tool name from log line.
*/
function extractToolName(line: string): string {
// Try to match "Calling tool: toolName" or similar patterns
const match =
line.match(/Calling tool[:\s]+([a-zA-Z0-9_-]+)/i) ||
line.match(/Running[:\s]+([a-zA-Z0-9_-]+)/i) ||
line.match(/Using tool[:\s]+([a-zA-Z0-9_-]+)/i);

if (match) {
return match[1];
}

// Fallback: return first part of line
return line.split(' ').slice(0, 3).join(' ') || 'unknown';
}
3 changes: 2 additions & 1 deletion app/native-server/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AgentStreamManager } from '../agent/stream-manager';
import { AgentChatService } from '../agent/chat-service';
import { CodexEngine } from '../agent/engines/codex';
import { ClaudeEngine } from '../agent/engines/claude';
import { QwenEngine } from '../agent/engines/qwen';
import { closeDb } from '../agent/db';
import { registerAgentRoutes } from './routes';

Expand Down Expand Up @@ -55,7 +56,7 @@ export class Server {
this.fastify = Fastify({ logger: SERVER_CONFIG.LOGGER_ENABLED });
this.agentStreamManager = new AgentStreamManager();
this.agentChatService = new AgentChatService({
engines: [new CodexEngine(), new ClaudeEngine()],
engines: [new CodexEngine(), new ClaudeEngine(), new QwenEngine()],
streamManager: this.agentStreamManager,
});
this.setupPlugins();
Expand Down
Loading