mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 07:10:55 +00:00
feat: support /compress and /summary commands for non-interactive & ACP
integration
This commit is contained in:
parent
cebe0448d0
commit
8aceddffa2
12 changed files with 720 additions and 147 deletions
|
|
@ -98,6 +98,14 @@ export class AgentSideConnection implements Client {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a custom notification to the client.
|
||||
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
||||
*/
|
||||
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
||||
return await this.#connection.sendNotification(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
|
|
@ -374,6 +382,7 @@ export interface Client {
|
|||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
sendCustomNotification<T>(method: string, params: T): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
qwenOAuth2Events,
|
||||
MCPServerConfig,
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
|
|
@ -349,12 +348,20 @@ class GeminiAgent {
|
|||
const sessionId = config.getSessionId();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const history = conversation
|
||||
? buildApiHistoryFromConversation(conversation)
|
||||
: undefined;
|
||||
const chat = history
|
||||
? await geminiClient.startChat(history)
|
||||
: await geminiClient.startChat();
|
||||
// Use GeminiClient to manage chat lifecycle properly
|
||||
// This ensures geminiClient.chat is in sync with the session's chat
|
||||
//
|
||||
// Note: When loading a session, config.initialize() has already been called
|
||||
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
||||
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
||||
// and automatically loads the conversation history into the chat instance.
|
||||
// So we only need to initialize if it hasn't been done yet.
|
||||
if (!geminiClient.isInitialized()) {
|
||||
await geminiClient.initialize();
|
||||
}
|
||||
|
||||
// Now get the chat instance that's managed by GeminiClient
|
||||
const chat = geminiClient.getChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
|
|
|
|||
|
|
@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises';
|
|||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
import {
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
} from '../../nonInteractiveCliCommands.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
|
|
@ -67,7 +69,7 @@ import { SubAgentTracker } from './SubAgentTracker.js';
|
|||
* Built-in commands that are allowed in ACP integration mode.
|
||||
* Only safe, read-only commands that don't require interactive UI.
|
||||
*/
|
||||
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init', 'summary', 'compress'];
|
||||
|
||||
/**
|
||||
* Session represents an active conversation session with the AI model.
|
||||
|
|
@ -167,7 +169,7 @@ export class Session implements SessionContext {
|
|||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[];
|
||||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - allow specific built-in commands for ACP integration
|
||||
|
|
@ -179,12 +181,15 @@ export class Session implements SessionContext {
|
|||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
if (slashCommandResult) {
|
||||
// Use the result from the slash command
|
||||
parts = slashCommandResult as Part[];
|
||||
} else {
|
||||
// Slash command didn't return a prompt, continue with normal processing
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
parts = await this.#processSlashCommandResult(
|
||||
slashCommandResult,
|
||||
params.prompt,
|
||||
);
|
||||
|
||||
// If parts is null, the command was fully handled (e.g., /summary completed)
|
||||
// Return early without sending to the model
|
||||
if (parts === null) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
|
|
@ -647,6 +652,103 @@ export class Session implements SessionContext {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the result of a slash command execution.
|
||||
*
|
||||
* Supported result types in ACP mode:
|
||||
* - submit_prompt: Submits content to the model
|
||||
* - stream_messages: Streams multiple messages to the client (ACP-specific)
|
||||
* - unsupported: Command cannot be executed in ACP mode
|
||||
* - no_command: No command was found, use original prompt
|
||||
*
|
||||
* Note: 'message' type is not supported in ACP mode - commands should use
|
||||
* 'stream_messages' instead for consistent async handling.
|
||||
*
|
||||
* @param result The result from handleSlashCommand
|
||||
* @param originalPrompt The original prompt blocks
|
||||
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
|
||||
*/
|
||||
async #processSlashCommandResult(
|
||||
result: NonInteractiveSlashCommandResult,
|
||||
originalPrompt: acp.ContentBlock[],
|
||||
): Promise<Part[] | null> {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
// Command wants to submit a prompt to the model
|
||||
// Convert PartListUnion to Part[]
|
||||
return normalizePartList(result.content);
|
||||
|
||||
case 'message': {
|
||||
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
||||
// by converting it to a stream_messages-like notification
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command: originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' '),
|
||||
messageType: result.messageType,
|
||||
message: result.content || '',
|
||||
});
|
||||
|
||||
if (result.messageType === 'error') {
|
||||
// Throw error to stop execution
|
||||
throw new Error(result.content || 'Slash command failed.');
|
||||
}
|
||||
// For info messages, return null to indicate command was handled
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'stream_messages': {
|
||||
// Command returns multiple messages via async generator (ACP-preferred)
|
||||
const command = originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Stream all messages to the client
|
||||
for await (const msg of result.messages) {
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command,
|
||||
messageType: msg.messageType,
|
||||
message: msg.content,
|
||||
});
|
||||
|
||||
// If we encounter an error message, throw after sending
|
||||
if (msg.messageType === 'error') {
|
||||
throw new Error(msg.content || 'Slash command failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// All messages sent successfully, return null to indicate command was handled
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'unsupported': {
|
||||
// Command returned an unsupported result type
|
||||
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
|
||||
throw new Error(unsupportedError);
|
||||
}
|
||||
|
||||
case 'no_command':
|
||||
// No command was found or executed, use original prompt
|
||||
return originalPrompt.map((block) => {
|
||||
if (block.type === 'text') {
|
||||
return { text: block.text };
|
||||
}
|
||||
throw new Error(`Unsupported block type: ${block.type}`);
|
||||
});
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
const _exhaustive: never = result;
|
||||
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
|
||||
throw new Error(unknownError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
|
|
|
|||
|
|
@ -590,6 +590,12 @@ export default {
|
|||
'No conversation found to summarize.': 'No conversation found to summarize.',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'Failed to generate project context summary: {{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'Saved project summary to {{filePathForDisplay}}.',
|
||||
'Saving project summary...': 'Saving project summary...',
|
||||
'Generating project summary...': 'Generating project summary...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Model
|
||||
|
|
|
|||
|
|
@ -604,6 +604,12 @@ export default {
|
|||
'Не найдено диалогов для создания сводки.',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'Сводка проекта сохранена в {{filePathForDisplay}}',
|
||||
'Saving project summary...': 'Сохранение сводки проекта...',
|
||||
'Generating project summary...': 'Генерация сводки проекта...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Модель
|
||||
|
|
|
|||
|
|
@ -560,6 +560,12 @@ export default {
|
|||
'No conversation found to summarize.': '未找到要总结的对话',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'生成项目上下文摘要失败:{{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'项目摘要已保存到 {{filePathForDisplay}}',
|
||||
'Saving project summary...': '正在保存项目摘要...',
|
||||
'Generating project summary...': '正在生成项目摘要...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Model
|
||||
|
|
|
|||
|
|
@ -42,6 +42,61 @@ import {
|
|||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
|
||||
const ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE = [
|
||||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
];
|
||||
|
||||
/**
|
||||
* Emits a final message for slash command results.
|
||||
* Note: systemMessage should already be emitted before calling this function.
|
||||
*/
|
||||
async function emitNonInteractiveFinalMessage(params: {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
adapter?: JsonOutputAdapterInterface;
|
||||
config: Config;
|
||||
startTimeMs: number;
|
||||
}): Promise<void> {
|
||||
const { message, isError, adapter, config } = params;
|
||||
|
||||
if (!adapter) {
|
||||
// Text output mode: write directly to stdout/stderr
|
||||
const target = isError ? process.stderr : process.stdout;
|
||||
target.write(`${message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON output mode: emit assistant message and result
|
||||
// (systemMessage should already be emitted by caller)
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: message,
|
||||
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
const outputFormat = config.getOutputFormat();
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
|
||||
adapter.emitResult({
|
||||
isError,
|
||||
durationMs: Date.now() - params.startTimeMs,
|
||||
apiDurationMs: 0,
|
||||
numTurns: 0,
|
||||
errorMessage: isError ? message : undefined,
|
||||
usage,
|
||||
stats,
|
||||
summary: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides optional overrides for `runNonInteractive` execution.
|
||||
*
|
||||
|
|
@ -115,6 +170,16 @@ export async function runNonInteractive(
|
|||
process.on('SIGINT', shutdownHandler);
|
||||
process.on('SIGTERM', shutdownHandler);
|
||||
|
||||
// Emit systemMessage first (always the first message in JSON mode)
|
||||
if (adapter) {
|
||||
const systemMessage = await buildSystemMessage(
|
||||
config,
|
||||
sessionId,
|
||||
permissionMode,
|
||||
);
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||
options.userMessage,
|
||||
);
|
||||
|
|
@ -127,11 +192,41 @@ export async function runNonInteractive(
|
|||
abortController,
|
||||
config,
|
||||
settings,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE,
|
||||
);
|
||||
if (slashCommandResult) {
|
||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||
initialPartList = slashCommandResult as PartListUnion;
|
||||
slashHandled = true;
|
||||
switch (slashCommandResult.type) {
|
||||
case 'submit_prompt':
|
||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||
initialPartList = slashCommandResult.content;
|
||||
slashHandled = true;
|
||||
break;
|
||||
case 'message': {
|
||||
// systemMessage already emitted above
|
||||
await emitNonInteractiveFinalMessage({
|
||||
message: slashCommandResult.content,
|
||||
isError: slashCommandResult.messageType === 'error',
|
||||
adapter,
|
||||
config,
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'stream_messages':
|
||||
// ACP exclusive - should not reach here in non-interactive mode
|
||||
throw new FatalInputError(
|
||||
'Stream messages mode is not supported in non-interactive CLI',
|
||||
);
|
||||
break;
|
||||
case 'unsupported':
|
||||
throw new FatalInputError(slashCommandResult.reason);
|
||||
case 'no_command':
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = slashCommandResult;
|
||||
throw new FatalInputError(
|
||||
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,15 +258,6 @@ export async function runNonInteractive(
|
|||
const initialParts = normalizePartList(initialPartList);
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||
|
||||
if (adapter) {
|
||||
const systemMessage = await buildSystemMessage(
|
||||
config,
|
||||
sessionId,
|
||||
permissionMode,
|
||||
);
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let isFirstTurn = true;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
import type { PartListUnion } from '@google/genai';
|
||||
import { parseSlashCommand } from './utils/commands.js';
|
||||
import {
|
||||
FatalInputError,
|
||||
Logger,
|
||||
uiTelemetryService,
|
||||
type Config,
|
||||
|
|
@ -19,11 +18,151 @@ import {
|
|||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './ui/commands/types.js';
|
||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
|
||||
/**
|
||||
* Result of handling a slash command in non-interactive mode.
|
||||
*
|
||||
* Supported types:
|
||||
* - 'submit_prompt': Submits content to the model (supports all modes)
|
||||
* - 'message': Returns a single message (supports non-interactive JSON/text only)
|
||||
* - 'stream_messages': Streams multiple messages (supports ACP only)
|
||||
* - 'unsupported': Command cannot be executed in this mode
|
||||
* - 'no_command': No command was found or executed
|
||||
*/
|
||||
export type NonInteractiveSlashCommandResult =
|
||||
| {
|
||||
type: 'submit_prompt';
|
||||
content: PartListUnion;
|
||||
}
|
||||
| {
|
||||
type: 'message';
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'stream_messages';
|
||||
messages: AsyncGenerator<
|
||||
{ messageType: 'info' | 'error'; content: string },
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: 'unsupported';
|
||||
reason: string;
|
||||
originalType: string;
|
||||
}
|
||||
| {
|
||||
type: 'no_command';
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
|
||||
*
|
||||
* Only the following result types are supported in non-interactive mode:
|
||||
* - submit_prompt: Submits content to the model (all modes)
|
||||
* - message: Returns a single message (non-interactive JSON/text only)
|
||||
* - stream_messages: Streams multiple messages (ACP only)
|
||||
*
|
||||
* All other result types are converted to 'unsupported'.
|
||||
*
|
||||
* @param result The result from executing a slash command action
|
||||
* @returns A NonInteractiveSlashCommandResult describing the outcome
|
||||
*/
|
||||
function handleCommandResult(
|
||||
result: SlashCommandActionReturn,
|
||||
): NonInteractiveSlashCommandResult {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: result.content,
|
||||
};
|
||||
|
||||
case 'message':
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: result.messageType,
|
||||
content: result.content,
|
||||
};
|
||||
|
||||
case 'stream_messages':
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: result.messages,
|
||||
};
|
||||
|
||||
//
|
||||
/**
|
||||
* Currently return types below are never generated due to the
|
||||
* whitelist of allowed slash commands in ACP and non-interactive mode.
|
||||
* We'll try to add more supported return types in the future.
|
||||
*/
|
||||
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Tool execution from slash commands is not supported in non-interactive mode.',
|
||||
originalType: 'tool',
|
||||
};
|
||||
|
||||
case 'quit':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
|
||||
originalType: 'quit',
|
||||
};
|
||||
|
||||
case 'dialog':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
|
||||
originalType: 'dialog',
|
||||
};
|
||||
|
||||
case 'load_history':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
|
||||
originalType: 'load_history',
|
||||
};
|
||||
|
||||
case 'confirm_shell_commands':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
|
||||
originalType: 'confirm_shell_commands',
|
||||
};
|
||||
|
||||
case 'confirm_action':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
|
||||
originalType: 'confirm_action',
|
||||
};
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
const _exhaustive: never = result;
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
|
||||
originalType: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters commands based on the allowed built-in command names.
|
||||
*
|
||||
|
|
@ -63,10 +202,8 @@ function filterCommandsForNonInteractive(
|
|||
* @param settings The loaded settings
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||
* found and results in a prompt, or `undefined` otherwise.
|
||||
* @throws {FatalInputError} if the command result is not supported in
|
||||
* non-interactive mode.
|
||||
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
|
||||
* the outcome of the command execution.
|
||||
*/
|
||||
export const handleSlashCommand = async (
|
||||
rawQuery: string,
|
||||
|
|
@ -74,12 +211,21 @@ export const handleSlashCommand = async (
|
|||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<PartListUnion | undefined> => {
|
||||
): Promise<NonInteractiveSlashCommandResult> => {
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return;
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
const isAcpMode = config.getExperimentalZedIntegration();
|
||||
const isInteractive = config.isInteractive();
|
||||
|
||||
const executionMode = isAcpMode
|
||||
? 'acp'
|
||||
: isInteractive
|
||||
? 'interactive'
|
||||
: 'non_interactive';
|
||||
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
|
|
@ -103,64 +249,58 @@ export const handleSlashCommand = async (
|
|||
filteredCommands,
|
||||
);
|
||||
|
||||
if (commandToExecute) {
|
||||
if (commandToExecute.action) {
|
||||
// Not used by custom commands but may be in the future.
|
||||
const sessionStats: SessionStatsState = {
|
||||
sessionId: config?.getSessionId(),
|
||||
sessionStartTime: new Date(),
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 1,
|
||||
};
|
||||
|
||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||
|
||||
const context: CommandContext = {
|
||||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
session: {
|
||||
stats: sessionStats,
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
invocation: {
|
||||
raw: trimmed,
|
||||
name: commandToExecute.name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await commandToExecute.action(context, args);
|
||||
|
||||
if (result) {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
return result.content;
|
||||
case 'confirm_shell_commands':
|
||||
// This result indicates a command attempted to confirm shell commands.
|
||||
// However note that currently, ShellTool is excluded in non-interactive
|
||||
// mode unless 'YOLO mode' is active, so confirmation actually won't
|
||||
// occur because of YOLO mode.
|
||||
// This ensures that if a command *does* request confirmation (e.g.
|
||||
// in the future with more granular permissions), it's handled appropriately.
|
||||
throw new FatalInputError(
|
||||
'Exiting due to a confirmation prompt requested by the command.',
|
||||
);
|
||||
default:
|
||||
throw new FatalInputError(
|
||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!commandToExecute) {
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
return;
|
||||
if (!commandToExecute.action) {
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
// Not used by custom commands but may be in the future.
|
||||
const sessionStats: SessionStatsState = {
|
||||
sessionId: config?.getSessionId(),
|
||||
sessionStartTime: new Date(),
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 1,
|
||||
};
|
||||
|
||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||
|
||||
const context: CommandContext = {
|
||||
executionMode,
|
||||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
session: {
|
||||
stats: sessionStats,
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
invocation: {
|
||||
raw: trimmed,
|
||||
name: commandToExecute.name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await commandToExecute.action(context, args);
|
||||
|
||||
if (!result) {
|
||||
// Command executed but returned no result (e.g., void return)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Command executed successfully.',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle different result types
|
||||
return handleCommandResult(result);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export const compressCommand: SlashCommand = {
|
|||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
if (ui.pendingItem) {
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
|
|
@ -40,13 +42,80 @@ export const compressCommand: SlashCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
try {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
const config = context.services.config;
|
||||
const geminiClient = config?.getGeminiClient();
|
||||
if (!config || !geminiClient) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const doCompress = async () => {
|
||||
const promptId = `compress-${Date.now()}`;
|
||||
const compressed = await context.services.config
|
||||
?.getGeminiClient()
|
||||
?.tryCompressChat(promptId, true);
|
||||
if (compressed) {
|
||||
return await geminiClient.tryCompressChat(promptId, true);
|
||||
};
|
||||
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: 'Compressing context...',
|
||||
};
|
||||
const compressed = await doCompress();
|
||||
if (!compressed) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: t('Failed to compress chat history.'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||
};
|
||||
} catch (e) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { type: 'stream_messages', messages: messages() };
|
||||
}
|
||||
|
||||
try {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
}
|
||||
|
||||
const compressed = await doCompress();
|
||||
|
||||
if (!compressed) {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to compress chat history.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.COMPRESSION,
|
||||
|
|
@ -59,27 +128,39 @@ export const compressCommand: SlashCommand = {
|
|||
} as HistoryItemCompression,
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||
};
|
||||
} catch (e) {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history.'),
|
||||
text: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
ui.setPendingItem(null);
|
||||
if (executionMode === 'interactive') {
|
||||
ui.setPendingItem(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export const summaryCommand: SlashCommand = {
|
|||
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
const { ui } = context;
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
@ -43,8 +45,8 @@ export const summaryCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
// Check if already generating summary
|
||||
if (ui.pendingItem) {
|
||||
// Check if already generating summary (interactive UI only)
|
||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: 'error' as const,
|
||||
|
|
@ -63,29 +65,15 @@ export const summaryCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const generateSummaryMarkdown = async (): Promise<string> => {
|
||||
// Get the current chat history
|
||||
const chat = geminiClient.getChat();
|
||||
const history = chat.getHistory();
|
||||
|
||||
if (history.length <= 2) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('No conversation found to summarize.'),
|
||||
};
|
||||
throw new Error(t('No conversation found to summarize.'));
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const pendingMessage: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage: 'generating',
|
||||
},
|
||||
};
|
||||
ui.setPendingItem(pendingMessage);
|
||||
|
||||
// Build the conversation context for summary generation
|
||||
const conversationContext = history.map((message) => ({
|
||||
role: message.role,
|
||||
|
|
@ -121,19 +109,21 @@ export const summaryCommand: SlashCommand = {
|
|||
|
||||
if (!markdownSummary) {
|
||||
throw new Error(
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
t(
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update loading message to show saving progress
|
||||
ui.setPendingItem({
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage: 'saving',
|
||||
},
|
||||
});
|
||||
return markdownSummary;
|
||||
};
|
||||
|
||||
const saveSummaryToDisk = async (
|
||||
markdownSummary: string,
|
||||
): Promise<{
|
||||
filePathForDisplay: string;
|
||||
fullPath: string;
|
||||
}> => {
|
||||
// Ensure .qwen directory exists
|
||||
const projectRoot = config.getProjectRoot();
|
||||
const qwenDir = path.join(projectRoot, '.qwen');
|
||||
|
|
@ -155,25 +145,46 @@ export const summaryCommand: SlashCommand = {
|
|||
|
||||
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
||||
|
||||
// Clear pending item and show success message
|
||||
return {
|
||||
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
|
||||
fullPath: summaryPath,
|
||||
};
|
||||
};
|
||||
|
||||
const emitInteractivePending = (stage: 'generating' | 'saving') => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
const pendingMessage: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage,
|
||||
},
|
||||
};
|
||||
ui.setPendingItem(pendingMessage);
|
||||
};
|
||||
|
||||
const completeInteractive = (filePathForDisplay: string) => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
ui.setPendingItem(null);
|
||||
const completedSummaryItem: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: false,
|
||||
stage: 'completed',
|
||||
filePath: '.qwen/PROJECT_SUMMARY.md',
|
||||
filePath: filePathForDisplay,
|
||||
},
|
||||
};
|
||||
ui.addItem(completedSummaryItem, Date.now());
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '', // Empty content since we show the message in UI component
|
||||
};
|
||||
} catch (error) {
|
||||
// Clear pending item on error
|
||||
const failInteractive = (error: unknown) => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
ui.setPendingItem(null);
|
||||
ui.addItem(
|
||||
{
|
||||
|
|
@ -187,6 +198,96 @@ export const summaryCommand: SlashCommand = {
|
|||
},
|
||||
Date.now(),
|
||||
);
|
||||
};
|
||||
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
try {
|
||||
emitInteractivePending('generating');
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Generating project summary...'),
|
||||
};
|
||||
|
||||
const markdownSummary = await generateSummaryMarkdown();
|
||||
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Saving project summary...'),
|
||||
};
|
||||
const { filePathForDisplay } =
|
||||
await saveSummaryToDisk(markdownSummary);
|
||||
|
||||
completeInteractive(filePathForDisplay);
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Saved project summary to {{filePathForDisplay}}.', {
|
||||
filePathForDisplay,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
failInteractive(error);
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: t(
|
||||
'Failed to generate project context summary: {{error}}',
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: messages(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
emitInteractivePending('generating');
|
||||
const markdownSummary = await generateSummaryMarkdown();
|
||||
emitInteractivePending('saving');
|
||||
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
|
||||
completeInteractive(filePathForDisplay);
|
||||
|
||||
if (executionMode === 'non_interactive') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Saved project summary to ${filePathForDisplay}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Interactive mode: UI components already display progress and completion.
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '',
|
||||
};
|
||||
} catch (error) {
|
||||
// Convert "no conversation" into a clean info message for non-interactive / interactive modes.
|
||||
const msg =
|
||||
error instanceof Error ? error.message : t('Unknown error occurred.');
|
||||
|
||||
if (msg === t('No conversation found to summarize.')) {
|
||||
if (executionMode === 'interactive') {
|
||||
// Keep interactive behavior: show as a normal message.
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
|
||||
failInteractive(error);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ import type {
|
|||
|
||||
// Grouped dependencies for clarity and easier mocking
|
||||
export interface CommandContext {
|
||||
/**
|
||||
* Execution mode for the current invocation.
|
||||
*
|
||||
* - interactive: React/Ink UI mode
|
||||
* - non_interactive: non-interactive CLI mode (text/json)
|
||||
* - acp: ACP/Zed integration mode
|
||||
*/
|
||||
executionMode?: 'interactive' | 'non_interactive' | 'acp';
|
||||
// Invocation properties for when commands are called.
|
||||
invocation?: {
|
||||
/** The raw, untrimmed input string from the user. */
|
||||
|
|
@ -108,6 +116,19 @@ export interface MessageActionReturn {
|
|||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that streams multiple messages.
|
||||
* Used for long-running operations that need to send progress updates.
|
||||
*/
|
||||
export interface StreamMessagesActionReturn {
|
||||
type: 'stream_messages';
|
||||
messages: AsyncGenerator<
|
||||
{ messageType: 'info' | 'error'; content: string },
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that needs to open a dialog.
|
||||
*/
|
||||
|
|
@ -174,6 +195,7 @@ export interface ConfirmActionReturn {
|
|||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| StreamMessagesActionReturn
|
||||
| QuitActionReturn
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
|
|
|
|||
|
|
@ -520,6 +520,13 @@ export const useSlashCommandProcessor = (
|
|||
true,
|
||||
);
|
||||
}
|
||||
case 'stream_messages': {
|
||||
// stream_messages is only used in ACP/Zed integration mode
|
||||
// and should not be returned in interactive UI mode
|
||||
throw new Error(
|
||||
'stream_messages result type is not supported in interactive mode',
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue