From a0b1f01f3138ed7119cef5323ec19c995cdadb3e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 4 Apr 2026 10:55:58 +0800 Subject: [PATCH 1/8] fix(vscode-ide-companion/session): force fresh sessions for new chats Ensure explicit new-session actions bypass active ACP session reuse so the VS Code sidebar clears context correctly. Add regression coverage for the agent manager and webview new-session entry points. --- .../src/services/qwenAgentManager.test.ts | 27 +++++++++++++++++ .../src/services/qwenAgentManager.ts | 7 +++-- .../handlers/SessionMessageHandler.test.ts | 29 +++++++++++++++++++ .../webview/handlers/SessionMessageHandler.ts | 10 +++++-- .../webview/providers/WebViewProvider.test.ts | 24 +++++++++++++++ .../src/webview/providers/WebViewProvider.ts | 2 +- 6 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts index 8df67c51e9c..6bf2a163300 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -103,3 +103,30 @@ describe('QwenAgentManager.setModelFromUi', () => { expect(onModelChanged).toHaveBeenCalledWith(selectedModel); }); }); + +describe('QwenAgentManager.createNewSession', () => { + it('creates a fresh ACP session when explicitly requested even if one is already active', async () => { + const manager = new QwenAgentManager(); + const connection = { + currentSessionId: 'session-1', + newSession: vi.fn().mockImplementation(async () => { + connection.currentSessionId = 'session-2'; + return { sessionId: 'session-2' }; + }), + authenticate: vi.fn(), + }; + + ( + manager as unknown as { + connection: typeof connection; + } + ).connection = connection; + + const newSessionId = await manager.createNewSession('/workspace', { + forceNew: true, + } as never); + + expect(connection.newSession).toHaveBeenCalledWith('/workspace'); + expect(newSessionId).toBe('session-2'); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 883a226eea6..3c9dda64777 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -82,6 +82,7 @@ interface AgentConnectOptions { } interface AgentSessionOptions { autoAuthenticate?: boolean; + forceNew?: boolean; } export class QwenAgentManager { @@ -1163,8 +1164,10 @@ export class QwenAgentManager { options?: AgentSessionOptions, ): Promise { const autoAuthenticate = options?.autoAuthenticate ?? true; - // Reuse existing session if present - if (this.connection.currentSessionId) { + const forceNew = options?.forceNew ?? false; + // Reuse the current session for implicit session bootstrap paths. + // Explicit "new session" actions must bypass this and call session/new. + if (!forceNew && this.connection.currentSessionId) { console.log( '[QwenAgentManager] createNewSession: reusing existing session', this.connection.currentSessionId, diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 32484c943ed..ff873cfda48 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -161,4 +161,33 @@ describe('SessionMessageHandler', () => { }, ]); }); + + it('forces a fresh ACP session when the webview requests a new session', async () => { + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + createNewSession: vi.fn().mockResolvedValue('session-2'), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'newQwenSession', + }); + + expect(agentManager.createNewSession).toHaveBeenCalledWith('/workspace', { + forceNew: true, + }); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 2ee1e6dd860..8fab3919de0 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -587,7 +587,7 @@ export class SessionMessageHandler extends BaseMessageHandler { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - await this.agentManager.createNewSession(workingDir); + await this.agentManager.createNewSession(workingDir, { forceNew: true }); this.sendToWebView({ type: 'conversationCleared', @@ -726,8 +726,12 @@ export class SessionMessageHandler extends BaseMessageHandler { // If we are connected, try to create a fresh ACP session so user can interact if (this.agentManager.isConnected) { try { - const newAcpSessionId = - await this.agentManager.createNewSession(workingDir); + const newAcpSessionId = await this.agentManager.createNewSession( + workingDir, + { + forceNew: true, + }, + ); this.currentConversationId = newAcpSessionId; diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index ffd587470b2..6ba2d3bf86f 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -68,6 +68,7 @@ vi.mock('../../services/qwenAgentManager.js', () => ({ onPermissionRequest = vi.fn(); onAskUserQuestion = vi.fn(); disconnect = vi.fn(); + createNewSession = vi.fn(); }, })); @@ -288,3 +289,26 @@ describe('WebViewProvider.attachToView', () => { expect(panelPostMessage).not.toHaveBeenCalled(); }); }); + +describe('WebViewProvider.createNewSession', () => { + it('forces a fresh ACP session for the sidebar new-session action', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const agentManager = ( + provider as unknown as { + agentManager: { + createNewSession: ReturnType; + }; + } + ).agentManager; + + await provider.createNewSession(); + + expect(agentManager.createNewSession).toHaveBeenCalledWith( + '/workspace-root', + { forceNew: true }, + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index b1cebfe4c57..3e7d38a2b29 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1614,7 +1614,7 @@ export class WebViewProvider { const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); // Create new Qwen session via agent manager - await this.agentManager.createNewSession(workingDir); + await this.agentManager.createNewSession(workingDir, { forceNew: true }); // Clear current conversation UI this.sendMessageToWebView({ From 35b3dd50026501d331fa7d65b2d6f916e0851c2d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 4 Apr 2026 14:09:43 +0800 Subject: [PATCH 2/8] fix(vscode): remove core runtime imports from webview bundle Replace the runtime import of `isSupportedImageMimeType` from `@qwen-code/qwen-code-core` with a local `SUPPORTED_PASTED_IMAGE_MIME_TYPES` set in the vscode-ide-companion package. The webview is bundled for a browser environment where Node.js-only core modules are unavailable, so keeping the MIME list local avoids esbuild failures during development. Added tests to verify the local list stays aligned with core and that the webview bundle does not contain core runtime imports. --- .../src/utils/imageSupport.bundle.test.ts | 49 +++++++++++++++++++ .../src/utils/imageSupport.test.ts | 17 +++++++ .../src/utils/imageSupport.ts | 9 ++-- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts create mode 100644 packages/vscode-ide-companion/src/utils/imageSupport.test.ts diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts new file mode 100644 index 00000000000..601848c6a07 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import esbuild from 'esbuild'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +describe('imageSupport browser bundling', () => { + it('does not leave qwen-code-core runtime imports in the webview bundle', async () => { + const result = await esbuild.build({ + entryPoints: [ + fileURLToPath(new URL('./imageSupport.ts', import.meta.url)), + ], + bundle: true, + format: 'iife', + platform: 'browser', + write: false, + logLevel: 'silent', + external: ['@qwen-code/qwen-code-core'], + }); + + const output = result.outputFiles[0]?.text ?? ''; + + expect(output).not.toContain('@qwen-code/qwen-code-core'); + expect(output).not.toContain('supportedImageFormats.js'); + }); + + it('does not leave qwen-code-core runtime imports in the App webview bundle', async () => { + const result = await esbuild.build({ + entryPoints: [ + fileURLToPath(new URL('../webview/App.tsx', import.meta.url)), + ], + bundle: true, + format: 'iife', + platform: 'browser', + write: false, + logLevel: 'silent', + external: ['@qwen-code/qwen-code-core'], + }); + + const output = result.outputFiles[0]?.text ?? ''; + + expect(output).not.toContain('@qwen-code/qwen-code-core'); + expect(output).not.toContain('tokenLimits.js'); + }); +}); diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.test.ts new file mode 100644 index 00000000000..b2b78d0ce5a --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/imageSupport.test.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { SUPPORTED_IMAGE_MIME_TYPES } from '@qwen-code/qwen-code-core/src/utils/request-tokenizer/supportedImageFormats.js'; +import { SUPPORTED_PASTED_IMAGE_MIME_TYPES } from './imageSupport.js'; + +describe('imageSupport constants', () => { + it('keeps the browser-safe pasted image list aligned with core-supported formats', () => { + expect(SUPPORTED_PASTED_IMAGE_MIME_TYPES).toEqual( + new Set(SUPPORTED_IMAGE_MIME_TYPES), + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.ts b/packages/vscode-ide-companion/src/utils/imageSupport.ts index 97ca54a4266..f06d8532427 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { isSupportedImageMimeType } from '@qwen-code/qwen-code-core/src/utils/request-tokenizer/supportedImageFormats.js'; - // ---------- Types ---------- export interface ImageAttachment { @@ -73,6 +71,11 @@ const PASTED_IMAGE_MIME_TO_EXTENSION: Record = { 'image/webp': '.webp', }; +// Keep this list aligned with packages/core/src/utils/request-tokenizer/supportedImageFormats.ts. +export const SUPPORTED_PASTED_IMAGE_MIME_TYPES = new Set( + Object.keys(PASTED_IMAGE_MIME_TO_EXTENSION), +); + const DISPLAYABLE_IMAGE_EXTENSION_TO_MIME: Record = { '.bmp': 'image/bmp', '.gif': 'image/gif', @@ -86,7 +89,7 @@ const DISPLAYABLE_IMAGE_EXTENSION_TO_MIME: Record = { }; export function isSupportedPastedImageMimeType(mimeType: string): boolean { - return isSupportedImageMimeType(mimeType); + return SUPPORTED_PASTED_IMAGE_MIME_TYPES.has(mimeType); } export function getImageExtensionForMimeType(mimeType: string): string { From 9f317e8a819dad7a8fe7c0fcd3a8fa1754d7db7a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 4 Apr 2026 14:10:11 +0800 Subject: [PATCH 3/8] fix(vscode): reset context usage display on new session (#2847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webview context-usage bar did not clear when the user started a new session because the old code always fell back to DEFAULT_TOKEN_LIMIT, producing a stale percentage even after usageStats and modelInfo were both cleared. Key changes: - Extract `knownTokenLimit()` in core/tokenLimits.ts that returns `undefined` for unrecognized models instead of a default, keeping `tokenLimit()` behavior unchanged. - In acpModelInfo.ts, derive `_meta.contextLimit` from the known-model table when the ACP payload omits a numeric limit. - Extract `computeContextUsage()` into its own module, which returns `null` when no trusted numeric limit is available — the UI then correctly hides the context bar. - Remove the `@qwen-code/qwen-code-core` runtime import from App.tsx so the webview bundle stays free of Node-only dependencies. Closes #2847 --- packages/core/src/core/tokenLimits.test.ts | 16 ++++ packages/core/src/core/tokenLimits.ts | 40 ++++++--- .../src/utils/acpModelInfo.test.ts | 52 +++++++++++ .../src/utils/acpModelInfo.ts | 27 +++++- .../vscode-ide-companion/src/webview/App.tsx | 52 +---------- .../src/webview/utils/contextUsage.test.ts | 87 +++++++++++++++++++ .../src/webview/utils/contextUsage.ts | 42 +++++++++ 7 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/utils/contextUsage.ts diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index 872e3b1cab7..a8c520b4fe6 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { normalize, tokenLimit, + knownTokenLimit, DEFAULT_TOKEN_LIMIT, DEFAULT_OUTPUT_TOKEN_LIMIT, } from './tokenLimits.js'; @@ -234,6 +235,21 @@ describe('tokenLimit', () => { }); }); +describe('knownTokenLimit', () => { + it('returns a limit for known input models', () => { + expect(knownTokenLimit('qwen3-max')).toBe(262144); + expect(knownTokenLimit('gpt-5')).toBe(272000); + }); + + it('returns a limit for known output models', () => { + expect(knownTokenLimit('qwen3-max', 'output')).toBe(32768); + }); + + it('returns undefined for unknown models instead of the default fallback', () => { + expect(knownTokenLimit('unknown-model-v1.0')).toBeUndefined(); + }); +}); + describe('tokenLimit with output type', () => { describe('latest models output limits', () => { it('should return correct output limits for GPT-5.x', () => { diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 108b80c73c6..ff0966430a6 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -184,6 +184,22 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ [/^kimi-k2\.5/, LIMITS['32k']], ]; +function findTokenLimit( + model: Model, + type: TokenLimitType = 'input', +): TokenCount | undefined { + const norm = normalize(model); + const patterns = type === 'output' ? OUTPUT_PATTERNS : PATTERNS; + + for (const [regex, limit] of patterns) { + if (regex.test(norm)) { + return limit; + } + } + + return undefined; +} + /** * Check if a model has an explicitly defined output token limit. * This distinguishes between models with known limits in OUTPUT_PATTERNS @@ -197,6 +213,13 @@ export function hasExplicitOutputLimit(model: Model): boolean { return OUTPUT_PATTERNS.some(([regex]) => regex.test(norm)); } +export function knownTokenLimit( + model: Model, + type: TokenLimitType = 'input', +): TokenCount | undefined { + return findTokenLimit(model, type); +} + /** * Return the token limit for a model string based on the specified type. * @@ -216,17 +239,8 @@ export function tokenLimit( model: Model, type: TokenLimitType = 'input', ): TokenCount { - const norm = normalize(model); - - // Choose the appropriate patterns based on token type - const patterns = type === 'output' ? OUTPUT_PATTERNS : PATTERNS; - - for (const [regex, limit] of patterns) { - if (regex.test(norm)) { - return limit; - } - } - - // Return appropriate default based on token type - return type === 'output' ? DEFAULT_OUTPUT_TOKEN_LIMIT : DEFAULT_TOKEN_LIMIT; + return ( + knownTokenLimit(model, type) ?? + (type === 'output' ? DEFAULT_OUTPUT_TOKEN_LIMIT : DEFAULT_TOKEN_LIMIT) + ); } diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts index d69d4056595..03ff8ee8cfc 100644 --- a/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts @@ -136,6 +136,26 @@ describe('extractSessionModelState', () => { // The function should still return a state with empty availableModels expect(result?.availableModels).toHaveLength(0); }); + + it('derives contextLimit for known models when the ACP payload omits it', () => { + const result = extractSessionModelState({ + models: { + currentModelId: 'qwen3-max', + availableModels: [{ modelId: 'qwen3-max', name: 'Qwen3 Max' }], + }, + }); + + expect(result).toEqual({ + currentModelId: 'qwen3-max', + availableModels: [ + { + modelId: 'qwen3-max', + name: 'Qwen3 Max', + _meta: { contextLimit: 262144 }, + }, + ], + }); + }); }); describe('extractModelInfoFromNewSessionResult', () => { @@ -205,4 +225,36 @@ describe('extractModelInfoFromNewSessionResult', () => { expect(extractModelInfoFromNewSessionResult({})).toBeNull(); expect(extractModelInfoFromNewSessionResult(null)).toBeNull(); }); + + it('derives contextLimit for known models when the payload has null metadata', () => { + expect( + extractModelInfoFromNewSessionResult({ + model: { + name: 'Qwen3 Max', + modelId: 'qwen3-max', + _meta: null, + }, + }), + ).toEqual({ + name: 'Qwen3 Max', + modelId: 'qwen3-max', + _meta: { contextLimit: 262144 }, + }); + }); + + it('preserves null contextLimit for unknown models', () => { + expect( + extractModelInfoFromNewSessionResult({ + model: { + name: 'Unknown', + modelId: 'unknown-model-v1.0', + _meta: { contextLimit: null }, + }, + }), + ).toEqual({ + name: 'Unknown', + modelId: 'unknown-model-v1.0', + _meta: { contextLimit: null }, + }); + }); }); diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts index d2c8b5e1b45..e9d92caf06d 100644 --- a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts @@ -5,6 +5,7 @@ */ import type { ModelInfo } from '@agentclientprotocol/sdk'; +import { knownTokenLimit } from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; type AcpMeta = Record; @@ -19,6 +20,15 @@ const asMeta = (value: unknown): AcpMeta | null | undefined => { return undefined; }; +const getContextLimitFromMeta = ( + meta: AcpMeta | null | undefined, +): number | null | undefined => { + const metaLimit = meta?.['contextLimit']; + return typeof metaLimit === 'number' || metaLimit === null + ? metaLimit + : undefined; +}; + const normalizeModelInfo = (value: unknown): ModelInfo | null => { if (!value || typeof value !== 'object') { return null; @@ -48,10 +58,25 @@ const normalizeModelInfo = (value: unknown): ModelInfo | null => { // Back-compat: older implementations used `contextLimit` at the top-level. const legacyContextLimit = obj['contextLimit']; - const contextLimit = + const legacyLimit = typeof legacyContextLimit === 'number' || legacyContextLimit === null ? legacyContextLimit : undefined; + const metaLimit = getContextLimitFromMeta(metaFromWire); + const derivedLimit = knownTokenLimit(modelId || name); + + // Priority: legacy numeric > meta numeric > derived from known model > explicit null > undefined. + // An explicit `null` from the server means "limit intentionally unknown"; `undefined` means "not provided". + const contextLimit = + typeof legacyLimit === 'number' + ? legacyLimit + : typeof metaLimit === 'number' + ? metaLimit + : typeof derivedLimit === 'number' + ? derivedLimit + : legacyLimit === null || metaLimit === null + ? null + : undefined; let mergedMeta: AcpMeta | null | undefined = metaFromWire; if (typeof contextLimit !== 'undefined') { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 3f01f30e519..63b17000d93 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -52,11 +52,8 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; -import { - DEFAULT_TOKEN_LIMIT, - tokenLimit, -} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; +import { computeContextUsage } from './utils/contextUsage.js'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -208,52 +205,7 @@ export const App: React.FC = () => { const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); - const contextUsage = useMemo(() => { - if (!usageStats && !modelInfo) { - return null; - } - - const modelName = - modelInfo?.modelId && typeof modelInfo.modelId === 'string' - ? modelInfo.modelId - : modelInfo?.name && typeof modelInfo.name === 'string' - ? modelInfo.name - : undefined; - - // Note: In the webview context, the contextWindowSize is already reflected in - // modelInfo._meta.contextLimit which is computed on the extension side with the proper config. - // We only use tokenLimit as a fallback if metaLimit is not available. - const derivedLimit = - modelName && modelName.length > 0 - ? tokenLimit(modelName, 'input') - : undefined; - - const metaLimitRaw = modelInfo?._meta?.['contextLimit']; - const metaLimit = - typeof metaLimitRaw === 'number' || metaLimitRaw === null - ? metaLimitRaw - : undefined; - - const limit = - usageStats?.tokenLimit ?? - metaLimit ?? - derivedLimit ?? - DEFAULT_TOKEN_LIMIT; - - const used = usageStats?.usage?.promptTokens ?? 0; - if (typeof limit !== 'number' || limit <= 0 || used < 0) { - return null; - } - const percentLeft = Math.max( - 0, - Math.min(100, Math.round(((limit - used) / limit) * 100)), - ); - return { - percentLeft, - usedTokens: used, - tokenLimit: limit, - }; - }, [usageStats, modelInfo]); + const contextUsage = useMemo(() => computeContextUsage(usageStats, modelInfo), [usageStats, modelInfo]); // Track a lightweight signature of workspace files to detect content changes even when length is unchanged const workspaceFilesSignature = useMemo( diff --git a/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts b/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts new file mode 100644 index 00000000000..d1e07ca185c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { computeContextUsage } from './contextUsage.js'; + +describe('computeContextUsage', () => { + it('returns null when there is no trusted token limit', () => { + expect( + computeContextUsage( + { + usage: { + promptTokens: 1234, + }, + }, + { + modelId: 'unknown-model', + name: 'Unknown Model', + }, + ), + ).toBeNull(); + }); + + it('prefers usageStats.tokenLimit over model metadata', () => { + expect( + computeContextUsage( + { + usage: { + promptTokens: 1000, + }, + tokenLimit: 4000, + }, + { + modelId: 'qwen3-max', + name: 'Qwen3 Max', + _meta: { contextLimit: 8000 }, + }, + ), + ).toEqual({ + percentLeft: 75, + usedTokens: 1000, + tokenLimit: 4000, + }); + }); + + it('falls back to model metadata when usageStats does not include a limit', () => { + expect( + computeContextUsage( + { + usage: { + promptTokens: 2000, + }, + }, + { + modelId: 'qwen3-max', + name: 'Qwen3 Max', + _meta: { contextLimit: 8000 }, + }, + ), + ).toEqual({ + percentLeft: 75, + usedTokens: 2000, + tokenLimit: 8000, + }); + }); + + it('uses inputTokens when promptTokens is unavailable', () => { + expect( + computeContextUsage( + { + usage: { + inputTokens: 3000, + }, + tokenLimit: 12000, + }, + null, + ), + ).toEqual({ + percentLeft: 75, + usedTokens: 3000, + tokenLimit: 12000, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts new file mode 100644 index 00000000000..aa62149e7b2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ModelInfo } from '@agentclientprotocol/sdk'; +import type { ContextUsage } from '@qwen-code/webui'; +import type { UsageStatsPayload } from '../../types/chatTypes.js'; + +export function computeContextUsage( + usageStats: UsageStatsPayload | null, + modelInfo: ModelInfo | null, +): ContextUsage | null { + if (!usageStats && !modelInfo) { + return null; + } + + const metaLimitRaw = modelInfo?._meta?.['contextLimit']; + const metaLimit = + typeof metaLimitRaw === 'number' || metaLimitRaw === null + ? metaLimitRaw + : undefined; + const limit = usageStats?.tokenLimit ?? metaLimit; + const used = + usageStats?.usage?.inputTokens ?? usageStats?.usage?.promptTokens ?? 0; + + if (typeof limit !== 'number' || limit <= 0 || used < 0) { + return null; + } + + const percentLeft = Math.max( + 0, + Math.min(100, Math.round(((limit - used) / limit) * 100)), + ); + + return { + percentLeft, + usedTokens: used, + tokenLimit: limit, + }; +} From ee4caeb94ca0122d2dd1dbd248b6a28c9d8e9d00 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 14:38:20 +0800 Subject: [PATCH 4/8] fix(vscode-ide-companion/webview): reset state on new session --- .../handlers/SessionMessageHandler.test.ts | 5 ++ .../webview/handlers/SessionMessageHandler.ts | 1 + .../webview/hooks/useWebViewMessages.test.ts | 49 ++++++++++++++++++ .../src/webview/hooks/useWebViewMessages.ts | 50 +++++++++++++++---- .../webview/providers/WebViewProvider.test.ts | 10 ++++ .../src/webview/providers/WebViewProvider.ts | 1 + .../src/webview/utils/contextUsage.ts | 4 ++ 7 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index ff873cfda48..e2ae6d83c62 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -186,8 +186,13 @@ describe('SessionMessageHandler', () => { type: 'newQwenSession', }); + expect(handler.getCurrentConversationId()).toBeNull(); expect(agentManager.createNewSession).toHaveBeenCalledWith('/workspace', { forceNew: true, }); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'conversationCleared', + data: {}, + }); }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 8fab3919de0..b0a0179cd9a 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -588,6 +588,7 @@ export class SessionMessageHandler extends BaseMessageHandler { const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); await this.agentManager.createNewSession(workingDir, { forceNew: true }); + this.currentConversationId = null; this.sendToWebView({ type: 'conversationCleared', diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts new file mode 100644 index 00000000000..d22a27716d9 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { resetConversationState } from './useWebViewMessages.js'; + +describe('resetConversationState', () => { + it('clears retained usage stats when a conversation is reset', () => { + const clearMessages = vi.fn(); + const clearToolCalls = vi.fn(); + const setCurrentSessionId = vi.fn(); + const setCurrentSessionTitle = vi.fn(); + const setUsageStats = vi.fn(); + const clearImageResolutions = vi.fn(); + const postMessage = vi.fn(); + + resetConversationState({ + handlers: { + messageHandling: { + clearMessages, + }, + clearToolCalls, + sessionManagement: { + setCurrentSessionId, + setCurrentSessionTitle, + }, + setUsageStats, + }, + clearImageResolutions, + vscode: { + postMessage, + }, + }); + + expect(clearMessages).toHaveBeenCalled(); + expect(clearToolCalls).toHaveBeenCalled(); + expect(setCurrentSessionId).toHaveBeenCalledWith(null); + expect(clearImageResolutions).toHaveBeenCalled(); + expect(setUsageStats).toHaveBeenCalledWith(undefined); + expect(setCurrentSessionTitle).toHaveBeenCalledWith('Past Conversations'); + expect(postMessage).toHaveBeenCalledWith({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 8d5eef683a4..bbf97ad9d99 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -133,6 +133,41 @@ interface UseWebViewMessagesProps { setAvailableModels?: (models: ModelInfo[]) => void; } +type ConversationResetHandlers = { + messageHandling: Pick< + UseWebViewMessagesProps['messageHandling'], + 'clearMessages' + >; + clearToolCalls: UseWebViewMessagesProps['clearToolCalls']; + sessionManagement: Pick< + UseWebViewMessagesProps['sessionManagement'], + 'setCurrentSessionId' | 'setCurrentSessionTitle' + >; + setUsageStats?: UseWebViewMessagesProps['setUsageStats']; +}; + +export function resetConversationState({ + handlers, + clearImageResolutions, + vscode, +}: { + handlers: ConversationResetHandlers; + clearImageResolutions: () => void; + vscode: { postMessage: (message: unknown) => void }; +}) { + handlers.messageHandling.clearMessages(); + handlers.clearToolCalls(); + handlers.sessionManagement.setCurrentSessionId(null); + clearImageResolutions(); + handlers.setUsageStats?.(undefined); + handlers.sessionManagement.setCurrentSessionTitle('Past Conversations'); + // Reset the VS Code tab title to default label + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); +} + /** * WebView message handling Hook * Handles all messages from VSCode Extension uniformly @@ -914,17 +949,10 @@ export const useWebViewMessages = ({ break; case 'conversationCleared': - handlers.messageHandling.clearMessages(); - handlers.clearToolCalls(); - handlers.sessionManagement.setCurrentSessionId(null); - clearImageResolutions(); - handlers.sessionManagement.setCurrentSessionTitle( - 'Past Conversations', - ); - // Reset the VS Code tab title to default label - vscode.postMessage({ - type: 'updatePanelTitle', - data: { title: 'Qwen Code' }, + resetConversationState({ + handlers, + clearImageResolutions, + vscode, }); lastPlanSnapshotRef.current = null; break; diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index 6ba2d3bf86f..0062e0439d4 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -103,6 +103,8 @@ vi.mock('./MessageHandler.js', () => ({ setLoginHandler = vi.fn(); setPermissionHandler = vi.fn(); setAskUserQuestionHandler = vi.fn(); + setCurrentConversationId = vi.fn(); + getCurrentConversationId = vi.fn(() => null); setupFileWatchers = vi.fn(() => ({ dispose: vi.fn() })); appendStreamContent = vi.fn(); route = vi.fn(); @@ -303,6 +305,13 @@ describe('WebViewProvider.createNewSession', () => { }; } ).agentManager; + const messageHandler = ( + provider as unknown as { + messageHandler: { + setCurrentConversationId: ReturnType; + }; + } + ).messageHandler; await provider.createNewSession(); @@ -310,5 +319,6 @@ describe('WebViewProvider.createNewSession', () => { '/workspace-root', { forceNew: true }, ); + expect(messageHandler.setCurrentConversationId).toHaveBeenCalledWith(null); }); }); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 3e7d38a2b29..18d8a867219 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1615,6 +1615,7 @@ export class WebViewProvider { // Create new Qwen session via agent manager await this.agentManager.createNewSession(workingDir, { forceNew: true }); + this.messageHandler.setCurrentConversationId(null); // Clear current conversation UI this.sendMessageToWebView({ diff --git a/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts index aa62149e7b2..394cdb03611 100644 --- a/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts +++ b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts @@ -21,7 +21,11 @@ export function computeContextUsage( typeof metaLimitRaw === 'number' || metaLimitRaw === null ? metaLimitRaw : undefined; + // Intentionally avoid DEFAULT_TOKEN_LIMIT here. The footer should disappear + // when neither ACP nor trusted model metadata provides a numeric limit. const limit = usageStats?.tokenLimit ?? metaLimit; + // Prefer the ACP SDK's canonical inputTokens field and only fall back to the + // legacy promptTokens name for older payloads. const used = usageStats?.usage?.inputTokens ?? usageStats?.usage?.promptTokens ?? 0; From f457124ec611ee219f0c38847ab2ffd1f7ff619f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 15:23:33 +0800 Subject: [PATCH 5/8] test(vscode-ide-companion/webview): cover stale conversation reset --- .../src/webview/handlers/SessionMessageHandler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index e2ae6d83c62..de71c5aa2bc 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -178,7 +178,7 @@ describe('SessionMessageHandler', () => { const handler = new SessionMessageHandler( agentManager as never, conversationStore as never, - null, + 'conversation-1', sendToWebView, ); From 434e9c37ac9e217b2ef628b66558d7e32d5d80fc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 23:22:02 +0800 Subject: [PATCH 6/8] fix(vscode): remove webview token limit runtime import --- .../vscode-ide-companion/src/webview/App.tsx | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 3f01f30e519..1a3032c61a5 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -52,12 +52,11 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; -import { - DEFAULT_TOKEN_LIMIT, - tokenLimit, -} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; +// Keep aligned with packages/core/src/core/tokenLimits.ts. +const DEFAULT_CONTEXT_LIMIT = 131_072; + export const App: React.FC = () => { const vscode = useVSCode(); @@ -213,32 +212,16 @@ export const App: React.FC = () => { return null; } - const modelName = - modelInfo?.modelId && typeof modelInfo.modelId === 'string' - ? modelInfo.modelId - : modelInfo?.name && typeof modelInfo.name === 'string' - ? modelInfo.name - : undefined; - // Note: In the webview context, the contextWindowSize is already reflected in // modelInfo._meta.contextLimit which is computed on the extension side with the proper config. - // We only use tokenLimit as a fallback if metaLimit is not available. - const derivedLimit = - modelName && modelName.length > 0 - ? tokenLimit(modelName, 'input') - : undefined; - + // Avoid importing qwen-code-core into the browser bundle; fall back to the default limit locally. const metaLimitRaw = modelInfo?._meta?.['contextLimit']; const metaLimit = typeof metaLimitRaw === 'number' || metaLimitRaw === null ? metaLimitRaw : undefined; - const limit = - usageStats?.tokenLimit ?? - metaLimit ?? - derivedLimit ?? - DEFAULT_TOKEN_LIMIT; + const limit = usageStats?.tokenLimit ?? metaLimit ?? DEFAULT_CONTEXT_LIMIT; const used = usageStats?.usage?.promptTokens ?? 0; if (typeof limit !== 'number' || limit <= 0 || used < 0) { From 9b4368efb960df01a1975fcb6fd40f8329194f09 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 7 Apr 2026 15:15:06 +0800 Subject: [PATCH 7/8] fix(vscode): fully reset state for explicit new session --- .../src/services/qwenAgentManager.test.ts | 44 +++++ .../src/services/qwenAgentManager.ts | 5 +- .../handlers/SessionMessageHandler.test.ts | 2 +- .../webview/hooks/useWebViewMessages.test.ts | 9 + .../webview/hooks/useWebViewMessages.test.tsx | 164 ++++++++++++++++++ .../src/webview/hooks/useWebViewMessages.ts | 8 +- 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts index 6bf2a163300..667c5d4c702 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -129,4 +129,48 @@ describe('QwenAgentManager.createNewSession', () => { expect(connection.newSession).toHaveBeenCalledWith('/workspace'); expect(newSessionId).toBe('session-2'); }); + + it('creates a distinct fresh session after an in-flight bootstrap when forceNew is requested', async () => { + const manager = new QwenAgentManager(); + const connection = { + currentSessionId: null as string | null, + newSession: vi.fn().mockImplementation(async () => { + connection.currentSessionId = 'session-2'; + return { sessionId: 'session-2' }; + }), + authenticate: vi.fn(), + }; + + let resolveBootstrap: ((value: string | null) => void) | undefined; + const bootstrapSession = new Promise((resolve) => { + resolveBootstrap = (value) => { + connection.currentSessionId = value; + resolve(value); + }; + }); + + ( + manager as unknown as { + connection: typeof connection; + sessionCreateInFlight: Promise | null; + } + ).connection = connection; + ( + manager as unknown as { + sessionCreateInFlight: Promise | null; + } + ).sessionCreateInFlight = bootstrapSession; + + const newSessionPromise = manager.createNewSession('/workspace', { + forceNew: true, + } as never); + + expect(connection.newSession).not.toHaveBeenCalled(); + + resolveBootstrap?.('session-1'); + + await expect(newSessionPromise).resolves.toBe('session-2'); + expect(connection.newSession).toHaveBeenCalledTimes(1); + expect(connection.newSession).toHaveBeenCalledWith('/workspace'); + }); }); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 46ee8f56212..5fbe222e63d 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1206,7 +1206,10 @@ export class QwenAgentManager { console.log( '[QwenAgentManager] createNewSession: session creation already in flight', ); - return this.sessionCreateInFlight; + if (!forceNew) { + return this.sessionCreateInFlight; + } + await this.sessionCreateInFlight; } console.log('[QwenAgentManager] Creating new session...'); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 6624e136352..591a69493d4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -88,7 +88,7 @@ describe('SessionMessageHandler', () => { const handler = new SessionMessageHandler( agentManager as never, conversationStore as never, - null, + 'conversation-1', sendToWebView, ); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts index d22a27716d9..827190bf4c3 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts @@ -10,6 +10,9 @@ import { resetConversationState } from './useWebViewMessages.js'; describe('resetConversationState', () => { it('clears retained usage stats when a conversation is reset', () => { const clearMessages = vi.fn(); + const endStreaming = vi.fn(); + const clearWaitingForResponse = vi.fn(); + const clearThinking = vi.fn(); const clearToolCalls = vi.fn(); const setCurrentSessionId = vi.fn(); const setCurrentSessionTitle = vi.fn(); @@ -21,6 +24,9 @@ describe('resetConversationState', () => { handlers: { messageHandling: { clearMessages, + endStreaming, + clearWaitingForResponse, + clearThinking, }, clearToolCalls, sessionManagement: { @@ -35,6 +41,9 @@ describe('resetConversationState', () => { }, }); + expect(endStreaming).toHaveBeenCalled(); + expect(clearWaitingForResponse).toHaveBeenCalled(); + expect(clearThinking).toHaveBeenCalled(); expect(clearMessages).toHaveBeenCalled(); expect(clearToolCalls).toHaveBeenCalled(); expect(setCurrentSessionId).toHaveBeenCalledWith(null); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx new file mode 100644 index 00000000000..9b00c448de7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWebViewMessages } from './useWebViewMessages.js'; + +const { mockPostMessage, mockClearImageResolutions } = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), + mockClearImageResolutions: vi.fn(), +})); + +vi.mock('./useVSCode.js', () => ({ + useVSCode: () => ({ + postMessage: mockPostMessage, + }), +})); + +vi.mock('./useImage.js', () => ({ + useImageResolution: () => ({ + materializeMessages: (messages: T) => messages, + materializeMessage: (message: T) => message, + mergeResolvedImages: (messages: T) => messages, + clearImageResolutions: mockClearImageResolutions, + }), +})); + +function renderHookHarness(overrides?: { + setUsageStats?: ReturnType; + endStreaming?: ReturnType; + clearWaitingForResponse?: ReturnType; +}) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + const setUsageStats = overrides?.setUsageStats ?? vi.fn(); + const endStreaming = overrides?.endStreaming ?? vi.fn(); + const clearWaitingForResponse = + overrides?.clearWaitingForResponse ?? vi.fn(); + + const handlers = { + sessionManagement: { + currentSessionId: 'conversation-1', + setQwenSessions: vi.fn(), + setCurrentSessionId: vi.fn(), + setCurrentSessionTitle: vi.fn(), + setShowSessionSelector: vi.fn(), + setNextCursor: vi.fn(), + setHasMore: vi.fn(), + setIsLoading: vi.fn(), + }, + fileContext: { + setActiveFileName: vi.fn(), + setActiveFilePath: vi.fn(), + setActiveSelection: vi.fn(), + setWorkspaceFilesFromResponse: vi.fn(), + addFileReference: vi.fn(), + }, + messageHandling: { + setMessages: vi.fn(), + addMessage: vi.fn(), + clearMessages: vi.fn(), + startStreaming: vi.fn(), + appendStreamChunk: vi.fn(), + endStreaming, + breakAssistantSegment: vi.fn(), + breakThinkingSegment: vi.fn(), + appendThinkingChunk: vi.fn(), + clearThinking: vi.fn(), + setWaitingForResponse: vi.fn(), + clearWaitingForResponse, + }, + handleToolCallUpdate: vi.fn(), + clearToolCalls: vi.fn(), + setPlanEntries: vi.fn(), + handlePermissionRequest: vi.fn(), + handleAskUserQuestion: vi.fn(), + inputFieldRef: createRef(), + setInputText: vi.fn(), + setEditMode: vi.fn(), + setIsAuthenticated: vi.fn(), + setUsageStats, + setModelInfo: vi.fn(), + setAvailableCommands: vi.fn(), + setAvailableModels: vi.fn(), + }; + + function Harness() { + useWebViewMessages(handlers); + return null; + } + + act(() => { + root.render(); + }); + + return { container, root, handlers, setUsageStats, endStreaming, clearWaitingForResponse }; +} + +describe('useWebViewMessages', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it('fully resets local UI state when a conversation is cleared', () => { + const rendered = renderHookHarness(); + root = rendered.root; + container = rendered.container; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'conversationCleared', + data: {}, + }, + }), + ); + }); + + expect(rendered.handlers.messageHandling.clearMessages).toHaveBeenCalled(); + expect(rendered.handlers.clearToolCalls).toHaveBeenCalled(); + expect(rendered.handlers.sessionManagement.setCurrentSessionId).toHaveBeenCalledWith( + null, + ); + expect(rendered.endStreaming).toHaveBeenCalled(); + expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); + expect(mockClearImageResolutions).toHaveBeenCalled(); + expect(rendered.setUsageStats).toHaveBeenCalledWith(undefined); + expect( + rendered.handlers.sessionManagement.setCurrentSessionTitle, + ).toHaveBeenCalledWith('Past Conversations'); + expect(mockPostMessage).toHaveBeenCalledWith({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index bbf97ad9d99..ad84500eac5 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -136,7 +136,10 @@ interface UseWebViewMessagesProps { type ConversationResetHandlers = { messageHandling: Pick< UseWebViewMessagesProps['messageHandling'], - 'clearMessages' + | 'clearMessages' + | 'endStreaming' + | 'clearWaitingForResponse' + | 'clearThinking' >; clearToolCalls: UseWebViewMessagesProps['clearToolCalls']; sessionManagement: Pick< @@ -155,6 +158,9 @@ export function resetConversationState({ clearImageResolutions: () => void; vscode: { postMessage: (message: unknown) => void }; }) { + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearWaitingForResponse(); + handlers.messageHandling.clearThinking(); handlers.messageHandling.clearMessages(); handlers.clearToolCalls(); handlers.sessionManagement.setCurrentSessionId(null); From f27a0d901dc2146d871f507e6fb6b1ff60ada3e2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 8 Apr 2026 22:32:29 +0800 Subject: [PATCH 8/8] fix(vscode-ide-companion/webview): clear residual state on new session --- .../webview/hooks/useWebViewMessages.test.ts | 12 ++++ .../webview/hooks/useWebViewMessages.test.tsx | 71 +++++++++++++++++-- .../src/webview/hooks/useWebViewMessages.ts | 15 +++- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts index 827190bf4c3..411e25522a9 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts @@ -14,6 +14,10 @@ describe('resetConversationState', () => { const clearWaitingForResponse = vi.fn(); const clearThinking = vi.fn(); const clearToolCalls = vi.fn(); + const clearActiveExecToolCalls = vi.fn(); + const setPlanEntries = vi.fn(); + const handlePermissionRequest = vi.fn(); + const handleAskUserQuestion = vi.fn(); const setCurrentSessionId = vi.fn(); const setCurrentSessionTitle = vi.fn(); const setUsageStats = vi.fn(); @@ -29,6 +33,10 @@ describe('resetConversationState', () => { clearThinking, }, clearToolCalls, + clearActiveExecToolCalls, + setPlanEntries, + handlePermissionRequest, + handleAskUserQuestion, sessionManagement: { setCurrentSessionId, setCurrentSessionTitle, @@ -46,6 +54,10 @@ describe('resetConversationState', () => { expect(clearThinking).toHaveBeenCalled(); expect(clearMessages).toHaveBeenCalled(); expect(clearToolCalls).toHaveBeenCalled(); + expect(clearActiveExecToolCalls).toHaveBeenCalled(); + expect(setPlanEntries).toHaveBeenCalledWith([]); + expect(handlePermissionRequest).toHaveBeenCalledWith(null); + expect(handleAskUserQuestion).toHaveBeenCalledWith(null); expect(setCurrentSessionId).toHaveBeenCalledWith(null); expect(clearImageResolutions).toHaveBeenCalled(); expect(setUsageStats).toHaveBeenCalledWith(undefined); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx index 9b00c448de7..0fd14384e3e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx @@ -42,8 +42,7 @@ function renderHookHarness(overrides?: { const setUsageStats = overrides?.setUsageStats ?? vi.fn(); const endStreaming = overrides?.endStreaming ?? vi.fn(); - const clearWaitingForResponse = - overrides?.clearWaitingForResponse ?? vi.fn(); + const clearWaitingForResponse = overrides?.clearWaitingForResponse ?? vi.fn(); const handlers = { sessionManagement: { @@ -101,7 +100,14 @@ function renderHookHarness(overrides?: { root.render(); }); - return { container, root, handlers, setUsageStats, endStreaming, clearWaitingForResponse }; + return { + container, + root, + handlers, + setUsageStats, + endStreaming, + clearWaitingForResponse, + }; } describe('useWebViewMessages', () => { @@ -146,13 +152,18 @@ describe('useWebViewMessages', () => { expect(rendered.handlers.messageHandling.clearMessages).toHaveBeenCalled(); expect(rendered.handlers.clearToolCalls).toHaveBeenCalled(); - expect(rendered.handlers.sessionManagement.setCurrentSessionId).toHaveBeenCalledWith( - null, - ); + expect( + rendered.handlers.sessionManagement.setCurrentSessionId, + ).toHaveBeenCalledWith(null); expect(rendered.endStreaming).toHaveBeenCalled(); expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); expect(mockClearImageResolutions).toHaveBeenCalled(); expect(rendered.setUsageStats).toHaveBeenCalledWith(undefined); + expect(rendered.handlers.setPlanEntries).toHaveBeenCalledWith([]); + expect(rendered.handlers.handlePermissionRequest).toHaveBeenCalledWith( + null, + ); + expect(rendered.handlers.handleAskUserQuestion).toHaveBeenCalledWith(null); expect( rendered.handlers.sessionManagement.setCurrentSessionTitle, ).toHaveBeenCalledWith('Past Conversations'); @@ -161,4 +172,52 @@ describe('useWebViewMessages', () => { data: { title: 'Qwen Code' }, }); }); + + it('clears stale execute-tool tracking before the next session ends', () => { + const rendered = renderHookHarness(); + root = rendered.root; + container = rendered.container; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'toolCall', + data: { + toolCallId: 'exec-1', + kind: 'execute', + status: 'in_progress', + rawInput: 'ls', + }, + }, + }), + ); + }); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'conversationCleared', + data: {}, + }, + }), + ); + }); + + rendered.clearWaitingForResponse.mockClear(); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'streamEnd', + data: {}, + }, + }), + ); + }); + + expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index ad84500eac5..2373dac1efa 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -142,6 +142,10 @@ type ConversationResetHandlers = { | 'clearThinking' >; clearToolCalls: UseWebViewMessagesProps['clearToolCalls']; + clearActiveExecToolCalls: () => void; + setPlanEntries: UseWebViewMessagesProps['setPlanEntries']; + handlePermissionRequest: UseWebViewMessagesProps['handlePermissionRequest']; + handleAskUserQuestion: UseWebViewMessagesProps['handleAskUserQuestion']; sessionManagement: Pick< UseWebViewMessagesProps['sessionManagement'], 'setCurrentSessionId' | 'setCurrentSessionTitle' @@ -159,10 +163,14 @@ export function resetConversationState({ vscode: { postMessage: (message: unknown) => void }; }) { handlers.messageHandling.endStreaming(); + handlers.clearActiveExecToolCalls(); handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearThinking(); handlers.messageHandling.clearMessages(); handlers.clearToolCalls(); + handlers.setPlanEntries([]); + handlers.handlePermissionRequest(null); + handlers.handleAskUserQuestion(null); handlers.sessionManagement.setCurrentSessionId(null); clearImageResolutions(); handlers.setUsageStats?.(undefined); @@ -956,7 +964,12 @@ export const useWebViewMessages = ({ case 'conversationCleared': resetConversationState({ - handlers, + handlers: { + ...handlers, + clearActiveExecToolCalls: () => { + activeExecToolCallsRef.current.clear(); + }, + }, clearImageResolutions, vscode, });