fix(vscode): force fresh ACP session on new-session action (#2874)

* 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 <tanzhenxing1987@gmail.com>
This commit is contained in:
易良 2026-04-11 10:16:16 +08:00 committed by GitHub
parent 505be40f82
commit fb91acdf25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 839 additions and 107 deletions

View file

@ -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', () => {

View file

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

View file

@ -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<string | null>((resolve) => {
resolveBootstrap = (value) => {
connection.currentSessionId = value;
resolve(value);
};
});
(
manager as unknown as {
connection: typeof connection;
sessionCreateInFlight: Promise<string | null> | null;
}
).connection = connection;
(
manager as unknown as {
sessionCreateInFlight: Promise<string | null> | 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');
});
});

View file

@ -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<string | null> {
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...');

View file

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

View file

@ -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<string, unknown>;
@ -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') {

View file

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

View file

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

View file

@ -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<string, string> = {
@ -96,6 +71,11 @@ const PASTED_IMAGE_MIME_TO_EXTENSION: Record<string, string> = {
'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<string, string> = {
'.bmp': 'image/bmp',
'.gif': 'image/gif',
@ -109,7 +89,7 @@ const DISPLAYABLE_IMAGE_EXTENSION_TO_MIME: Record<string, string> = {
};
export function isSupportedPastedImageMimeType(mimeType: string): boolean {
return isSupportedImageMimeType(mimeType);
return SUPPORTED_PASTED_IMAGE_MIME_TYPES.has(mimeType);
}
export function getImageExtensionForMimeType(mimeType: string): string {

View file

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

View file

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

View file

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

View file

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

View file

@ -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: <T,>(messages: T) => messages,
materializeMessage: <T,>(message: T) => message,
mergeResolvedImages: <T,>(messages: T) => messages,
clearImageResolutions: mockClearImageResolutions,
}),
}));
function renderHookHarness(overrides?: {
setUsageStats?: ReturnType<typeof vi.fn>;
endStreaming?: ReturnType<typeof vi.fn>;
clearWaitingForResponse?: ReturnType<typeof vi.fn>;
}) {
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<HTMLDivElement>(),
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(<Harness />);
});
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();
});
});

View file

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

View file

@ -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<typeof vi.fn>;
};
}
).agentManager;
const messageHandler = (
provider as unknown as {
messageHandler: {
setCurrentConversationId: ReturnType<typeof vi.fn>;
};
}
).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();

View file

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

View file

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

View file

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