Skip to content

Commit 17eb20c

Browse files
authored
Merge pull request #1322 from QwenLM/mingholy/feat/headless-slash-commands
feat: support /compress and /summary commands for non-interactive & ACP
2 parents cebe044 + 5d59ceb commit 17eb20c

File tree

19 files changed

+1267
-245
lines changed

19 files changed

+1267
-245
lines changed

integration-tests/sdk-typescript/system-control.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,88 @@ describe('System Control (E2E)', () => {
314314
);
315315
});
316316
});
317+
318+
describe('supportedCommands API', () => {
319+
it('should return list of supported slash commands', async () => {
320+
const sessionId = crypto.randomUUID();
321+
const generator = (async function* () {
322+
yield {
323+
type: 'user',
324+
session_id: sessionId,
325+
message: { role: 'user', content: 'Hello' },
326+
parent_tool_use_id: null,
327+
} as SDKUserMessage;
328+
})();
329+
330+
const q = query({
331+
prompt: generator,
332+
options: {
333+
...SHARED_TEST_OPTIONS,
334+
cwd: testDir,
335+
model: 'qwen3-max',
336+
debug: false,
337+
},
338+
});
339+
340+
try {
341+
const result = await q.supportedCommands();
342+
// Start consuming messages to trigger initialization
343+
const messageConsumer = (async () => {
344+
try {
345+
for await (const _message of q) {
346+
// Just consume messages
347+
}
348+
} catch (error) {
349+
// Ignore errors from query being closed
350+
if (error instanceof Error && error.message !== 'Query is closed') {
351+
throw error;
352+
}
353+
}
354+
})();
355+
356+
// Verify result structure
357+
expect(result).toBeDefined();
358+
expect(result).toHaveProperty('commands');
359+
expect(Array.isArray(result?.['commands'])).toBe(true);
360+
361+
const commands = result?.['commands'] as string[];
362+
363+
// Verify default allowed built-in commands are present
364+
expect(commands).toContain('init');
365+
expect(commands).toContain('summary');
366+
expect(commands).toContain('compress');
367+
368+
// Verify commands are sorted
369+
const sortedCommands = [...commands].sort();
370+
expect(commands).toEqual(sortedCommands);
371+
372+
// Verify all commands are strings
373+
commands.forEach((cmd) => {
374+
expect(typeof cmd).toBe('string');
375+
expect(cmd.length).toBeGreaterThan(0);
376+
});
377+
378+
await q.close();
379+
await messageConsumer;
380+
} catch (error) {
381+
await q.close();
382+
throw error;
383+
}
384+
});
385+
386+
it('should throw error when supportedCommands is called on closed query', async () => {
387+
const q = query({
388+
prompt: 'Hello',
389+
options: {
390+
...SHARED_TEST_OPTIONS,
391+
cwd: testDir,
392+
model: 'qwen3-max',
393+
},
394+
});
395+
396+
await q.close();
397+
398+
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
399+
});
400+
});
317401
});

packages/cli/src/acp-integration/acp.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ export class AgentSideConnection implements Client {
9898
);
9999
}
100100

101+
/**
102+
* Sends a custom notification to the client.
103+
* Used for extension-specific notifications that are not part of the core ACP protocol.
104+
*/
105+
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
106+
return await this.#connection.sendNotification(method, params);
107+
}
108+
101109
/**
102110
* Request permission before running a tool
103111
*
@@ -374,6 +382,7 @@ export interface Client {
374382
): Promise<schema.RequestPermissionResponse>;
375383
sessionUpdate(params: schema.SessionNotification): Promise<void>;
376384
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
385+
sendCustomNotification<T>(method: string, params: T): Promise<void>;
377386
writeTextFile(
378387
params: schema.WriteTextFileRequest,
379388
): Promise<schema.WriteTextFileResponse>;

packages/cli/src/acp-integration/acpAgent.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
qwenOAuth2Events,
1616
MCPServerConfig,
1717
SessionService,
18-
buildApiHistoryFromConversation,
1918
type Config,
2019
type ConversationRecord,
2120
type DeviceAuthorizationData,
@@ -349,12 +348,20 @@ class GeminiAgent {
349348
const sessionId = config.getSessionId();
350349
const geminiClient = config.getGeminiClient();
351350

352-
const history = conversation
353-
? buildApiHistoryFromConversation(conversation)
354-
: undefined;
355-
const chat = history
356-
? await geminiClient.startChat(history)
357-
: await geminiClient.startChat();
351+
// Use GeminiClient to manage chat lifecycle properly
352+
// This ensures geminiClient.chat is in sync with the session's chat
353+
//
354+
// Note: When loading a session, config.initialize() has already been called
355+
// in newSessionConfig(), which in turn calls geminiClient.initialize().
356+
// The GeminiClient.initialize() method checks config.getResumedSessionData()
357+
// and automatically loads the conversation history into the chat instance.
358+
// So we only need to initialize if it hasn't been done yet.
359+
if (!geminiClient.isInitialized()) {
360+
await geminiClient.initialize();
361+
}
362+
363+
// Now get the chat instance that's managed by GeminiClient
364+
const chat = geminiClient.getChat();
358365

359366
const session = new Session(
360367
sessionId,

packages/cli/src/acp-integration/session/Session.ts

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises';
4141
import * as path from 'node:path';
4242
import { z } from 'zod';
4343
import { getErrorMessage } from '../../utils/errors.js';
44+
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
4445
import {
4546
handleSlashCommand,
4647
getAvailableCommands,
48+
type NonInteractiveSlashCommandResult,
4749
} from '../../nonInteractiveCliCommands.js';
4850
import type {
4951
AvailableCommand,
@@ -63,12 +65,6 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
6365
import { MessageEmitter } from './emitters/MessageEmitter.js';
6466
import { SubAgentTracker } from './SubAgentTracker.js';
6567

66-
/**
67-
* Built-in commands that are allowed in ACP integration mode.
68-
* Only safe, read-only commands that don't require interactive UI.
69-
*/
70-
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
71-
7268
/**
7369
* Session represents an active conversation session with the AI model.
7470
* It uses modular components for consistent event emission:
@@ -167,24 +163,26 @@ export class Session implements SessionContext {
167163
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
168164
const inputText = firstTextBlock?.text || '';
169165

170-
let parts: Part[];
166+
let parts: Part[] | null;
171167

172168
if (isSlashCommand(inputText)) {
173-
// Handle slash command - allow specific built-in commands for ACP integration
169+
// Handle slash command - uses default allowed commands (init, summary, compress)
174170
const slashCommandResult = await handleSlashCommand(
175171
inputText,
176172
pendingSend,
177173
this.config,
178174
this.settings,
179-
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
180175
);
181176

182-
if (slashCommandResult) {
183-
// Use the result from the slash command
184-
parts = slashCommandResult as Part[];
185-
} else {
186-
// Slash command didn't return a prompt, continue with normal processing
187-
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
177+
parts = await this.#processSlashCommandResult(
178+
slashCommandResult,
179+
params.prompt,
180+
);
181+
182+
// If parts is null, the command was fully handled (e.g., /summary completed)
183+
// Return early without sending to the model
184+
if (parts === null) {
185+
return { stopReason: 'end_turn' };
188186
}
189187
} else {
190188
// Normal processing for non-slash commands
@@ -295,11 +293,10 @@ export class Session implements SessionContext {
295293
async sendAvailableCommandsUpdate(): Promise<void> {
296294
const abortController = new AbortController();
297295
try {
296+
// Use default allowed commands from getAvailableCommands
298297
const slashCommands = await getAvailableCommands(
299298
this.config,
300-
this.settings,
301299
abortController.signal,
302-
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
303300
);
304301

305302
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
@@ -647,6 +644,103 @@ export class Session implements SessionContext {
647644
}
648645
}
649646

647+
/**
648+
* Processes the result of a slash command execution.
649+
*
650+
* Supported result types in ACP mode:
651+
* - submit_prompt: Submits content to the model
652+
* - stream_messages: Streams multiple messages to the client (ACP-specific)
653+
* - unsupported: Command cannot be executed in ACP mode
654+
* - no_command: No command was found, use original prompt
655+
*
656+
* Note: 'message' type is not supported in ACP mode - commands should use
657+
* 'stream_messages' instead for consistent async handling.
658+
*
659+
* @param result The result from handleSlashCommand
660+
* @param originalPrompt The original prompt blocks
661+
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
662+
*/
663+
async #processSlashCommandResult(
664+
result: NonInteractiveSlashCommandResult,
665+
originalPrompt: acp.ContentBlock[],
666+
): Promise<Part[] | null> {
667+
switch (result.type) {
668+
case 'submit_prompt':
669+
// Command wants to submit a prompt to the model
670+
// Convert PartListUnion to Part[]
671+
return normalizePartList(result.content);
672+
673+
case 'message': {
674+
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
675+
// by converting it to a stream_messages-like notification
676+
await this.client.sendCustomNotification('_qwencode/slash_command', {
677+
sessionId: this.sessionId,
678+
command: originalPrompt
679+
.filter((block) => block.type === 'text')
680+
.map((block) => (block.type === 'text' ? block.text : ''))
681+
.join(' '),
682+
messageType: result.messageType,
683+
message: result.content || '',
684+
});
685+
686+
if (result.messageType === 'error') {
687+
// Throw error to stop execution
688+
throw new Error(result.content || 'Slash command failed.');
689+
}
690+
// For info messages, return null to indicate command was handled
691+
return null;
692+
}
693+
694+
case 'stream_messages': {
695+
// Command returns multiple messages via async generator (ACP-preferred)
696+
const command = originalPrompt
697+
.filter((block) => block.type === 'text')
698+
.map((block) => (block.type === 'text' ? block.text : ''))
699+
.join(' ');
700+
701+
// Stream all messages to the client
702+
for await (const msg of result.messages) {
703+
await this.client.sendCustomNotification('_qwencode/slash_command', {
704+
sessionId: this.sessionId,
705+
command,
706+
messageType: msg.messageType,
707+
message: msg.content,
708+
});
709+
710+
// If we encounter an error message, throw after sending
711+
if (msg.messageType === 'error') {
712+
throw new Error(msg.content || 'Slash command failed.');
713+
}
714+
}
715+
716+
// All messages sent successfully, return null to indicate command was handled
717+
return null;
718+
}
719+
720+
case 'unsupported': {
721+
// Command returned an unsupported result type
722+
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
723+
throw new Error(unsupportedError);
724+
}
725+
726+
case 'no_command':
727+
// No command was found or executed, use original prompt
728+
return originalPrompt.map((block) => {
729+
if (block.type === 'text') {
730+
return { text: block.text };
731+
}
732+
throw new Error(`Unsupported block type: ${block.type}`);
733+
});
734+
735+
default: {
736+
// Exhaustiveness check
737+
const _exhaustive: never = result;
738+
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
739+
throw new Error(unknownError);
740+
}
741+
}
742+
}
743+
650744
async #resolvePrompt(
651745
message: acp.ContentBlock[],
652746
abortSignal: AbortSignal,

packages/cli/src/i18n/locales/en.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ export default {
258258
', Tab to change focus': ', Tab to change focus',
259259
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
260260
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
261+
'The command "/{{command}}" is not supported in non-interactive mode.':
262+
'The command "/{{command}}" is not supported in non-interactive mode.',
261263
// ============================================================================
262264
// Settings Labels
263265
// ============================================================================
@@ -590,6 +592,12 @@ export default {
590592
'No conversation found to summarize.': 'No conversation found to summarize.',
591593
'Failed to generate project context summary: {{error}}':
592594
'Failed to generate project context summary: {{error}}',
595+
'Saved project summary to {{filePathForDisplay}}.':
596+
'Saved project summary to {{filePathForDisplay}}.',
597+
'Saving project summary...': 'Saving project summary...',
598+
'Generating project summary...': 'Generating project summary...',
599+
'Failed to generate summary - no text content received from LLM response':
600+
'Failed to generate summary - no text content received from LLM response',
593601

594602
// ============================================================================
595603
// Commands - Model

packages/cli/src/i18n/locales/ru.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ export default {
260260
', Tab to change focus': ', Tab для смены фокуса',
261261
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
262262
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
263-
263+
'The command "/{{command}}" is not supported in non-interactive mode.':
264+
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
264265
// ============================================================================
265266
// Метки настроек
266267
// ============================================================================
@@ -604,6 +605,12 @@ export default {
604605
'Не найдено диалогов для создания сводки.',
605606
'Failed to generate project context summary: {{error}}':
606607
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
608+
'Saved project summary to {{filePathForDisplay}}.':
609+
'Сводка проекта сохранена в {{filePathForDisplay}}',
610+
'Saving project summary...': 'Сохранение сводки проекта...',
611+
'Generating project summary...': 'Генерация сводки проекта...',
612+
'Failed to generate summary - no text content received from LLM response':
613+
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
607614

608615
// ============================================================================
609616
// Команды - Модель

packages/cli/src/i18n/locales/zh.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ export default {
249249
', Tab to change focus': ',Tab 切换焦点',
250250
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
251251
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
252+
'The command "/{{command}}" is not supported in non-interactive mode.':
253+
'不支持在非交互模式下使用命令 "/{{command}}"。',
252254
// ============================================================================
253255
// Settings Labels
254256
// ============================================================================
@@ -560,6 +562,12 @@ export default {
560562
'No conversation found to summarize.': '未找到要总结的对话',
561563
'Failed to generate project context summary: {{error}}':
562564
'生成项目上下文摘要失败:{{error}}',
565+
'Saved project summary to {{filePathForDisplay}}.':
566+
'项目摘要已保存到 {{filePathForDisplay}}',
567+
'Saving project summary...': '正在保存项目摘要...',
568+
'Generating project summary...': '正在生成项目摘要...',
569+
'Failed to generate summary - no text content received from LLM response':
570+
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
563571

564572
// ============================================================================
565573
// Commands - Model

0 commit comments

Comments
 (0)