mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(vscode-ide-companion): support /insight command (#2593)
* feat(vscode-ide-companion): support /insight command Add ACP support for /insight progress streaming and report opening in the VSCode companion. Resolves #2023 * fix(cli): defer insight command runtime deps * test(cli): cover acp slash command allowlist * Revert "test(cli): cover acp slash command allowlist" This reverts commit3209274ab6. * Revert "fix(cli): defer insight command runtime deps" This reverts commit3b08491e46. * Reapply "fix(cli): defer insight command runtime deps" This reverts commit386c5c67d3. * Reapply "test(cli): cover acp slash command allowlist" This reverts commite2716140dd. * refactor(cli): simplify insight ACP integration - Replace `formatAcpInsightProgress` with `encodeAcpInsightProgress` using JSON payload - Move imports to top-level, no longer defer loading for non-ACP mode - Remove `INSIGHT_READY_MARKER` parsing from Session.ts as it's now handled by WebViewProvider * refactor: extract insight protocol markers to core package Move INSIGHT_PROGRESS_MARKER and INSIGHT_READY_MARKER from cli and vscode-ide-companion packages to @qwen-code/qwen-code-core for better shareability and to avoid duplication. Also extract ACP_ALLOWED_COMMANDS constant in Session.ts to improve readability and maintainability. * refactor(vscode-ide-companion): extract test helper to reduce webview mock duplication Introduce `setupAttachedProvider()` helper in WebViewProvider.test.ts to eliminate ~160 lines of repeated webview mock + provider setup code across 5 insight-related test cases. * feat(cli): 添加ACP执行模式到内置命令 当ACP启用时,将executionMode参数传递给所有内置命令, 使命令能够识别当前运行在ACP模式下并相应地调整行为。 test(cli): 为insight命令添加ACP进度消息流测试 新增测试验证insight命令在ACP模式下能够正确流式传输 进度消息,而不必等待生成完成。测试涵盖了从开始到完 成的整个进度更新过程。 refactor(core): 重构insight协议消息格式 将insight进度和就绪消息从基于标记字符串的格式 改为结构化的JSON格式,提供更好的类型安全和解析 可靠性。 feat(vscode-ide-companion): 支持新的insight消息协议 更新WebViewProvider以支持新的结构化insight消息协 议,能够正确解析和处理来自CLI的进度和就绪消息。 ``` * fix(vscode-ide-companion/insight): streamline insight progress handling Trim redundant CLI insight coverage around the ACP path. Keep the VS Code insight progress flow aligned with normalized slash commands and the updated progress layout. * fix(insight): restore slash commands after webview reload Cache available commands in the VS Code provider so webview restoration still exposes /insight without a manual login. Also remove the unused progress bar markup to keep the UI diff smaller. * Update packages/webui/src/index.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * fix(webui): remove duplicate insight card export --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
41f71ab7e7
commit
7cded6e0df
17 changed files with 914 additions and 16 deletions
|
|
@ -20,6 +20,12 @@ import type { LoadedSettings } from '../../config/settings.js';
|
|||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
||||
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE: [
|
||||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
'bug',
|
||||
],
|
||||
getAvailableCommands: vi.fn(),
|
||||
handleSlashCommand: vi.fn(),
|
||||
}));
|
||||
|
|
@ -51,7 +57,6 @@ describe('Session', () => {
|
|||
let switchModelSpy: ReturnType<typeof vi.fn>;
|
||||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
let mockToolRegistry: { getTool: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
currentModel = 'qwen3-code-plus';
|
||||
currentAuthType = AuthType.USE_OPENAI;
|
||||
|
|
@ -205,6 +210,10 @@ describe('Session', () => {
|
|||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
[
|
||||
...nonInteractiveCliCommands.ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
'insight',
|
||||
],
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import type { LoadedSettings } from '../../config/settings.js';
|
|||
import { z } from 'zod';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
import {
|
||||
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
|
|
@ -81,6 +82,11 @@ import { isSlashCommand } from '../../ui/utils/commandUtils.js';
|
|||
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
|
||||
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
|
||||
|
||||
const ACP_ALLOWED_COMMANDS = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
'insight',
|
||||
];
|
||||
|
||||
// Import modular session components
|
||||
import type {
|
||||
ApprovalModeValue,
|
||||
|
|
@ -324,12 +330,13 @@ export class Session implements SessionContext {
|
|||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - uses default allowed commands (init, summary, compress)
|
||||
// ACP supports the standard non-interactive built-ins plus /insight.
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ACP_ALLOWED_COMMANDS,
|
||||
);
|
||||
|
||||
parts = await this.#processSlashCommandResult(
|
||||
|
|
@ -965,6 +972,7 @@ export class Session implements SessionContext {
|
|||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
abortController.signal,
|
||||
ACP_ALLOWED_COMMANDS,
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import open from 'open';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import { parseInsightMessage, Storage } from '@qwen-code/qwen-code-core';
|
||||
import { insightCommand } from './insightCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
|
@ -63,4 +63,112 @@ describe('insightCommand', () => {
|
|||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams ACP progress messages without waiting for generation to finish', async () => {
|
||||
let resolveInsight: ((outputPath: string) => void) | null = null;
|
||||
let progressCallback:
|
||||
| ((stage: string, progress: number, detail?: string) => void)
|
||||
| null = null;
|
||||
|
||||
mockGenerateStaticInsight.mockImplementation(
|
||||
async (
|
||||
_projectsDir: string,
|
||||
onProgress: (stage: string, progress: number, detail?: string) => void,
|
||||
) => {
|
||||
progressCallback = onProgress;
|
||||
return await new Promise<string>((resolve) => {
|
||||
resolveInsight = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const acpContext = createMockCommandContext({
|
||||
executionMode: 'acp',
|
||||
services: {
|
||||
config: {} as CommandContext['services']['config'],
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
setPendingItem: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
if (!insightCommand.action) {
|
||||
throw new Error('insight command must have action');
|
||||
}
|
||||
|
||||
const actionPromise = insightCommand.action(acpContext, '');
|
||||
const initialResult = await Promise.race([
|
||||
actionPromise,
|
||||
new Promise<'pending'>((resolve) => {
|
||||
setTimeout(() => resolve('pending'), 0);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(initialResult).not.toBe('pending');
|
||||
expect(initialResult).toMatchObject({ type: 'stream_messages' });
|
||||
|
||||
if (!initialResult || initialResult === 'pending') {
|
||||
throw new Error('ACP insight result did not resolve immediately');
|
||||
}
|
||||
|
||||
const result = initialResult;
|
||||
if (result.type !== 'stream_messages') {
|
||||
throw new Error('ACP insight result must be stream_messages');
|
||||
}
|
||||
|
||||
const messagesPromise = (async () => {
|
||||
const messages: Array<{
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}> = [];
|
||||
for await (const message of result.messages) {
|
||||
messages.push(message);
|
||||
}
|
||||
return messages;
|
||||
})();
|
||||
|
||||
const emitProgress = progressCallback as
|
||||
| ((stage: string, progress: number, detail?: string) => void)
|
||||
| null;
|
||||
if (emitProgress) {
|
||||
emitProgress('Analyzing sessions', 42, '21/50');
|
||||
}
|
||||
const finishInsight = resolveInsight as
|
||||
| ((outputPath: string) => void)
|
||||
| null;
|
||||
if (finishInsight) {
|
||||
finishInsight(
|
||||
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await messagesPromise;
|
||||
|
||||
expect(messages[0]).toEqual({
|
||||
messageType: 'info',
|
||||
content: 'This may take a couple minutes. Sit tight!',
|
||||
});
|
||||
expect(parseInsightMessage(messages[1].content)).toEqual({
|
||||
type: 'insight_progress',
|
||||
stage: 'Starting insight generation...',
|
||||
progress: 0,
|
||||
detail: undefined,
|
||||
});
|
||||
expect(parseInsightMessage(messages[2].content)).toEqual({
|
||||
type: 'insight_progress',
|
||||
stage: 'Analyzing sessions',
|
||||
progress: 42,
|
||||
detail: '21/50',
|
||||
});
|
||||
expect(parseInsightMessage(messages[3].content)).toEqual({
|
||||
type: 'insight_ready',
|
||||
path: path.resolve(
|
||||
'runtime-output',
|
||||
'insights',
|
||||
'insight-2026-03-05.html',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import type { HistoryItemInsightProgress } from '../types.js';
|
|||
import { t } from '../../i18n/index.js';
|
||||
import { join } from 'path';
|
||||
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
|
||||
import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
createDebugLogger,
|
||||
encodeInsightProgressMessage,
|
||||
encodeInsightReadyMessage,
|
||||
Storage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import open from 'open';
|
||||
|
||||
const logger = createDebugLogger('DataProcessor');
|
||||
|
|
@ -36,6 +41,104 @@ export const insightCommand: SlashCommand = {
|
|||
context.services.config,
|
||||
);
|
||||
|
||||
if (context.executionMode === 'acp') {
|
||||
const pendingMessages: Array<{
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}> = [];
|
||||
let isComplete = false;
|
||||
let resume: (() => void) | null = null;
|
||||
|
||||
const flushResume = () => {
|
||||
const resolve = resume;
|
||||
if (!resolve) {
|
||||
return;
|
||||
}
|
||||
resume = null;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const pushMessage = (message: {
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}) => {
|
||||
pendingMessages.push(message);
|
||||
flushResume();
|
||||
};
|
||||
|
||||
const streamMessages = async function* (): AsyncGenerator<
|
||||
{ messageType: 'info' | 'error'; content: string },
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
while (!isComplete || pendingMessages.length > 0) {
|
||||
if (pendingMessages.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
while (pendingMessages.length > 0) {
|
||||
const message = pendingMessages.shift();
|
||||
if (message) {
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
pushMessage({
|
||||
messageType: 'info',
|
||||
content: t('This may take a couple minutes. Sit tight!'),
|
||||
});
|
||||
pushMessage({
|
||||
messageType: 'info',
|
||||
content: encodeInsightProgressMessage(
|
||||
t('Starting insight generation...'),
|
||||
0,
|
||||
),
|
||||
});
|
||||
|
||||
const outputPath = await insightGenerator.generateStaticInsight(
|
||||
projectsDir,
|
||||
(stage, progress, detail) => {
|
||||
pushMessage({
|
||||
messageType: 'info',
|
||||
content: encodeInsightProgressMessage(
|
||||
stage,
|
||||
progress,
|
||||
detail,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
pushMessage({
|
||||
messageType: 'info',
|
||||
content: encodeInsightReadyMessage(outputPath),
|
||||
});
|
||||
} catch (error) {
|
||||
pushMessage({
|
||||
messageType: 'error',
|
||||
content: t('Failed to generate insights: {{error}}', {
|
||||
error: (error as Error).message,
|
||||
}),
|
||||
});
|
||||
logger.error('Insight generation error:', error);
|
||||
} finally {
|
||||
isComplete = true;
|
||||
flushResume();
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: streamMessages(),
|
||||
};
|
||||
}
|
||||
|
||||
const updateProgress = (
|
||||
stage: string,
|
||||
progress: number,
|
||||
|
|
@ -60,16 +163,13 @@ export const insightCommand: SlashCommand = {
|
|||
Date.now(),
|
||||
);
|
||||
|
||||
// Initial progress
|
||||
updateProgress(t('Starting insight generation...'), 0);
|
||||
|
||||
// Generate the static insight HTML file
|
||||
const outputPath = await insightGenerator.generateStaticInsight(
|
||||
projectsDir,
|
||||
updateProgress,
|
||||
);
|
||||
|
||||
// Clear pending item
|
||||
context.ui.setPendingItem(null);
|
||||
|
||||
context.ui.addItem(
|
||||
|
|
@ -80,7 +180,6 @@ export const insightCommand: SlashCommand = {
|
|||
Date.now(),
|
||||
);
|
||||
|
||||
// Open the file in the default browser
|
||||
try {
|
||||
await open(outputPath);
|
||||
|
||||
|
|
@ -111,8 +210,8 @@ export const insightCommand: SlashCommand = {
|
|||
}
|
||||
|
||||
context.ui.setDebugMessage(t('Insights ready.'));
|
||||
return;
|
||||
} catch (error) {
|
||||
// Clear pending item on error
|
||||
context.ui.setPendingItem(null);
|
||||
|
||||
context.ui.addItem(
|
||||
|
|
@ -126,6 +225,7 @@ export const insightCommand: SlashCommand = {
|
|||
);
|
||||
|
||||
logger.error('Insight generation error:', error);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue