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 commit 3209274ab6.

* Revert "fix(cli): defer insight command runtime deps"

This reverts commit 3b08491e46.

* Reapply "fix(cli): defer insight command runtime deps"

This reverts commit 386c5c67d3.

* Reapply "test(cli): cover acp slash command allowlist"

This reverts commit e2716140dd.

* 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:
易良 2026-04-20 10:02:18 +08:00 committed by GitHub
parent 41f71ab7e7
commit 7cded6e0df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 914 additions and 16 deletions

View file

@ -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',

View file

@ -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

View file

@ -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',
),
});
});
});

View file

@ -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;
}
},
};