Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/cli/src/ui/utils/MarkdownDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lines[index + 1].match(tableSeparatorRegex)
) {
inTable = true;
tableHeaders = tableRowMatch[1].split(/(?<!\\)\|/).map((cell) => cell.trim().replaceAll('\\|', '|'));
tableHeaders = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
tableRows = [];
} else {
// Not a table, treat as regular text
Expand All @@ -127,7 +129,9 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
// Skip separator line - already handled
} else if (inTable && tableRowMatch) {
// Add table row
const cells = tableRowMatch[1].split(/(?<!\\)\|/).map((cell) => cell.trim().replaceAll('\\|', '|'));
const cells = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
// Ensure row has same column count as headers
while (cells.length < tableHeaders.length) {
cells.push('');
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/utils/TableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// Ensure table fits within terminal width
const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1);
const fixedWidth = columnWidths.length + 1;
const scaleFactor = totalWidth > contentWidth ? (contentWidth - fixedWidth) / (totalWidth - fixedWidth) : 1;
const scaleFactor =
totalWidth > contentWidth
? (contentWidth - fixedWidth) / (totalWidth - fixedWidth)
: 1;
const adjustedWidths = columnWidths.map((width) =>
Math.floor(width * scaleFactor),
);
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/core/tokenLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
normalize,
tokenLimit,
knownTokenLimit,
DEFAULT_TOKEN_LIMIT,
DEFAULT_OUTPUT_TOKEN_LIMIT,
} from './tokenLimits.js';
Expand Down Expand Up @@ -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', () => {
Expand Down
40 changes: 27 additions & 13 deletions packages/core/src/core/tokenLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 9 additions & 3 deletions packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface AgentConnectOptions {
}
interface AgentSessionOptions {
autoAuthenticate?: boolean;
forceNew?: boolean;
}

export class QwenAgentManager {
Expand Down Expand Up @@ -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) {
Comment thread
yiliang114 marked this conversation as resolved.
console.log(
'[QwenAgentManager] createNewSession: reusing existing session',
this.connection.currentSessionId,
Expand All @@ -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...');
Expand Down
52 changes: 52 additions & 0 deletions packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 },
});
});
});
27 changes: 26 additions & 1 deletion packages/vscode-ide-companion/src/utils/acpModelInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading