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