From fb91acdf25539d1428cb131173f8657b4e8c7b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 11 Apr 2026 10:16:16 +0800 Subject: [PATCH] fix(vscode): force fresh ACP session on new-session action (#2874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * 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. * fix(vscode): reset context usage display on new session (#2847) 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 * fix(vscode-ide-companion/webview): reset state on new session * test(vscode-ide-companion/webview): cover stale conversation reset * fix(vscode): remove webview token limit runtime import * fix(vscode): fully reset state for explicit new session * fix(vscode-ide-companion/webview): clear residual state on new session --------- Co-authored-by: tanzhenxin --- packages/core/src/core/tokenLimits.test.ts | 16 ++ packages/core/src/core/tokenLimits.ts | 40 +++- .../src/services/qwenAgentManager.test.ts | 71 ++++++ .../src/services/qwenAgentManager.ts | 12 +- .../src/utils/acpModelInfo.test.ts | 52 ++++ .../src/utils/acpModelInfo.ts | 27 ++- .../src/utils/imageSupport.bundle.test.ts | 49 ++++ .../src/utils/imageSupport.test.ts | 17 ++ .../src/utils/imageSupport.ts | 32 +-- .../vscode-ide-companion/src/webview/App.tsx | 52 +--- .../handlers/SessionMessageHandler.test.ts | 36 ++- .../webview/handlers/SessionMessageHandler.ts | 11 +- .../webview/hooks/useWebViewMessages.test.ts | 70 ++++++ .../webview/hooks/useWebViewMessages.test.tsx | 223 ++++++++++++++++++ .../src/webview/hooks/useWebViewMessages.ts | 69 +++++- .../webview/providers/WebViewProvider.test.ts | 33 ++- .../src/webview/providers/WebViewProvider.ts | 3 +- .../src/webview/utils/contextUsage.test.ts | 87 +++++++ .../src/webview/utils/contextUsage.ts | 46 ++++ 19 files changed, 839 insertions(+), 107 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 create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx 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 872e3b1ca..a8c520b4f 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 bcae91470..8b5cce6a3 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -191,6 +191,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 @@ -204,6 +220,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. * @@ -223,17 +246,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/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts index 8df67c51e..667c5d4c7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -103,3 +103,74 @@ 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'); + }); + + 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 fd7c7396d..5fbe222e6 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 { @@ -1190,8 +1191,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, @@ -1203,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/utils/acpModelInfo.test.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts index d69d40565..03ff8ee8c 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 d2c8b5e1b..e9d92caf0 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/utils/imageSupport.bundle.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts new file mode 100644 index 000000000..601848c6a --- /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 000000000..b2b78d0ce --- /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 4ff69a393..f06d85324 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.ts @@ -59,31 +59,6 @@ export function unescapePath(filePath: string): string { ); } -// ---------- Supported image MIME types ---------- -// Inlined from @qwen-code/qwen-code-core to avoid pulling Node.js-only modules -// into the browser webview bundle (esbuild marks core as external, but deep -// sub-path imports like core/src/utils/... bypass the external filter and cause -// "Dynamic require is not supported" at runtime). - -const SUPPORTED_IMAGE_MIME_TYPES: readonly string[] = [ - 'image/bmp', - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/tiff', - 'image/webp', - 'image/heic', -]; - -/** - * Check whether a MIME type is supported for pasted-image processing. - * @param mimeType - The MIME type string to validate - * @returns `true` when the type is in the supported list - */ -function isSupportedImageMimeType(mimeType: string): boolean { - return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType); -} - // ---------- Image format detection ---------- const PASTED_IMAGE_MIME_TO_EXTENSION: Record = { @@ -96,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', @@ -109,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 { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 67e904890..ec7461595 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -52,8 +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 '../utils/tokenLimits.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; +import { computeContextUsage } from './utils/contextUsage.js'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -205,52 +205,10 @@ 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/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index c1d709b56..591a69493 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, ); @@ -185,4 +185,38 @@ 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, + 'conversation-1', + sendToWebView, + ); + + await handler.handle({ + 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 381ab9e58..cf5d7fd8e 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -593,7 +593,8 @@ 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.currentConversationId = null; this.sendToWebView({ type: 'conversationCleared', @@ -732,8 +733,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/hooks/useWebViewMessages.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts new file mode 100644 index 000000000..411e25522 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts @@ -0,0 +1,70 @@ +/** + * @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 endStreaming = vi.fn(); + 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(); + const clearImageResolutions = vi.fn(); + const postMessage = vi.fn(); + + resetConversationState({ + handlers: { + messageHandling: { + clearMessages, + endStreaming, + clearWaitingForResponse, + clearThinking, + }, + clearToolCalls, + clearActiveExecToolCalls, + setPlanEntries, + handlePermissionRequest, + handleAskUserQuestion, + sessionManagement: { + setCurrentSessionId, + setCurrentSessionTitle, + }, + setUsageStats, + }, + clearImageResolutions, + vscode: { + postMessage, + }, + }); + + expect(endStreaming).toHaveBeenCalled(); + expect(clearWaitingForResponse).toHaveBeenCalled(); + 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); + 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.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx new file mode 100644 index 000000000..0fd14384e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx @@ -0,0 +1,223 @@ +/** + * @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.setPlanEntries).toHaveBeenCalledWith([]); + expect(rendered.handlers.handlePermissionRequest).toHaveBeenCalledWith( + null, + ); + expect(rendered.handlers.handleAskUserQuestion).toHaveBeenCalledWith(null); + expect( + rendered.handlers.sessionManagement.setCurrentSessionTitle, + ).toHaveBeenCalledWith('Past Conversations'); + expect(mockPostMessage).toHaveBeenCalledWith({ + type: 'updatePanelTitle', + 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 8d5eef683..2373dac1e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -133,6 +133,55 @@ interface UseWebViewMessagesProps { setAvailableModels?: (models: ModelInfo[]) => void; } +type ConversationResetHandlers = { + messageHandling: Pick< + UseWebViewMessagesProps['messageHandling'], + | 'clearMessages' + | 'endStreaming' + | 'clearWaitingForResponse' + | 'clearThinking' + >; + clearToolCalls: UseWebViewMessagesProps['clearToolCalls']; + clearActiveExecToolCalls: () => void; + setPlanEntries: UseWebViewMessagesProps['setPlanEntries']; + handlePermissionRequest: UseWebViewMessagesProps['handlePermissionRequest']; + handleAskUserQuestion: UseWebViewMessagesProps['handleAskUserQuestion']; + 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.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); + 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 +963,15 @@ 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: { + ...handlers, + clearActiveExecToolCalls: () => { + activeExecToolCallsRef.current.clear(); + }, + }, + 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 e42423a5c..de5f7d2f4 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -111,7 +111,7 @@ vi.mock('./MessageHandler.js', () => ({ setPermissionHandler = vi.fn(); setAskUserQuestionHandler = vi.fn(); setCurrentConversationId = vi.fn(); - getCurrentConversationId = vi.fn(() => 'conversation-1'); + getCurrentConversationId = vi.fn(() => null); setupFileWatchers = vi.fn(() => ({ dispose: vi.fn() })); appendStreamContent = vi.fn(); route = vi.fn(); @@ -299,6 +299,37 @@ describe('WebViewProvider.attachToView', () => { }); }); +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; + const messageHandler = ( + provider as unknown as { + messageHandler: { + setCurrentConversationId: ReturnType; + }; + } + ).messageHandler; + + await provider.createNewSession(); + + expect(agentManager.createNewSession).toHaveBeenCalledWith( + '/workspace-root', + { forceNew: true }, + ); + expect(messageHandler.setCurrentConversationId).toHaveBeenCalledWith(null); + }); +}); + describe('WebViewProvider initial model inheritance', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index be46d6166..9280b9c06 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1703,7 +1703,8 @@ 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 }); + this.messageHandler.setCurrentConversationId(null); // Clear current conversation UI this.sendMessageToWebView({ 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 000000000..d1e07ca18 --- /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 000000000..394cdb036 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts @@ -0,0 +1,46 @@ +/** + * @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; + // 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; + + 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, + }; +}