diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 5296c16ef..f77d43297 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -101,14 +101,6 @@ class GeminiAgent { })); const version = process.env['CLI_VERSION'] || process.version; - const modelName = this.config.getModel(); - const modelInfo = - modelName && modelName.length > 0 - ? { - name: modelName, - contextLimit: tokenLimit(modelName), - } - : undefined; return { protocolVersion: acp.PROTOCOL_VERSION, @@ -122,7 +114,6 @@ class GeminiAgent { currentModeId: currentApprovalMode as ApprovalModeValue, availableModes, }, - modelInfo, agentCapabilities: { loadSession: true, promptCapabilities: { @@ -175,9 +166,30 @@ class GeminiAgent { this.setupFileSystem(config); const session = await this.createAndStoreSession(config); + const configuredModel = ( + config.getModel() || + this.config.getModel() || + '' + ).trim(); + const modelId = configuredModel || 'default'; + const modelName = configuredModel || modelId; return { sessionId: session.getId(), + models: { + currentModelId: modelId, + availableModels: [ + { + modelId, + name: modelName, + description: null, + _meta: { + contextLimit: tokenLimit(modelId), + }, + }, + ], + _meta: null, + }, }; } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 0eccb2786..e0da29fea 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -255,8 +255,27 @@ export const authenticateUpdateSchema = z.object({ export type AuthenticateUpdate = z.infer; +// ACP `_meta` extensibility field: implementations MUST NOT assume keys. +export const acpMetaSchema = z.record(z.unknown()).nullable().optional(); + +export const modelIdSchema = z.string(); + +export const modelInfoSchema = z.object({ + _meta: acpMetaSchema, + description: z.string().nullable().optional(), + modelId: modelIdSchema, + name: z.string(), +}); + +export const sessionModelStateSchema = z.object({ + _meta: acpMetaSchema, + availableModels: z.array(modelInfoSchema), + currentModelId: modelIdSchema, +}); + export const newSessionResponseSchema = z.object({ sessionId: z.string(), + models: sessionModelStateSchema, }); export const loadSessionResponseSchema = z.null(); @@ -418,17 +437,11 @@ export const agentInfoSchema = z.object({ version: z.string(), }); -export const modelInfoSchema = z.object({ - name: z.string(), - contextLimit: z.number().optional().nullable(), -}); - export const initializeResponseSchema = z.object({ agentCapabilities: agentCapabilitiesSchema, agentInfo: agentInfoSchema, authMethods: z.array(authMethodSchema), modes: modesDataSchema, - modelInfo: modelInfoSchema.optional(), protocolVersion: z.number(), }); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 6bd3e878b..9b9b9cbc8 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -8,6 +8,7 @@ import type { AcpSessionUpdate, AcpPermissionRequest, AuthenticateUpdateNotification, + ModelInfo, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; @@ -25,6 +26,7 @@ import { } from '../services/qwenConnectionHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { authMethod } from '../types/acpTypes.js'; +import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; @@ -178,23 +180,6 @@ export class QwenAgentManager { availableModes: modes.availableModes, }); } - - const modelInfo = obj['modelInfo'] as - | { - name?: string; - contextLimit?: number | null; - } - | undefined; - if ( - modelInfo && - typeof modelInfo.name === 'string' && - this.callbacks.onModelInfo - ) { - this.callbacks.onModelInfo({ - name: modelInfo.name, - contextLimit: modelInfo.contextLimit, - }); - } } catch (err) { console.warn('[QwenAgentManager] onInitialized parse error:', err); } @@ -213,12 +198,16 @@ export class QwenAgentManager { options?: AgentConnectOptions, ): Promise { this.currentWorkingDir = workingDir; - return this.connectionHandler.connect( + const res = await this.connectionHandler.connect( this.connection, workingDir, cliEntryPath, options, ); + if (res.modelInfo && this.callbacks.onModelInfo) { + this.callbacks.onModelInfo(res.modelInfo); + } + return res; } /** @@ -1109,9 +1098,10 @@ export class QwenAgentManager { this.sessionCreateInFlight = (async () => { try { + let newSessionResult: unknown; // Try to create a new ACP session. If Qwen asks for auth, let it handle authentication. try { - await this.connection.newSession(workingDir); + newSessionResult = await this.connection.newSession(workingDir); } catch (err) { const requiresAuth = isAuthenticationRequiredError(err); @@ -1133,7 +1123,7 @@ export class QwenAgentManager { ); // Add a slight delay to ensure auth state is settled await new Promise((resolve) => setTimeout(resolve, 300)); - await this.connection.newSession(workingDir); + newSessionResult = await this.connection.newSession(workingDir); } catch (reauthErr) { console.error( '[QwenAgentManager] Re-authentication failed:', @@ -1145,6 +1135,13 @@ export class QwenAgentManager { throw err; } } + + const modelInfo = + extractModelInfoFromNewSessionResult(newSessionResult); + if (modelInfo && this.callbacks.onModelInfo) { + this.callbacks.onModelInfo(modelInfo); + } + const newSessionId = this.connection.currentSessionId; console.log( '[QwenAgentManager] New session created with ID:', @@ -1286,9 +1283,7 @@ export class QwenAgentManager { /** * Register callback for model info updates */ - onModelInfo( - callback: (info: { name: string; contextLimit?: number | null }) => void, - ): void { + onModelInfo(callback: (info: ModelInfo) => void): void { this.callbacks.onModelInfo = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index c66ee23c6..0be0cacaa 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -13,10 +13,13 @@ import type { AcpConnection } from './acpConnection.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { authMethod } from '../types/acpTypes.js'; +import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js'; +import type { ModelInfo } from '../types/acpTypes.js'; export interface QwenConnectionResult { sessionCreated: boolean; requiresAuth: boolean; + modelInfo?: ModelInfo; } /** @@ -44,6 +47,7 @@ export class QwenConnectionHandler { const autoAuthenticate = options?.autoAuthenticate ?? true; let sessionCreated = false; let requiresAuth = false; + let modelInfo: ModelInfo | undefined; // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; @@ -66,13 +70,15 @@ export class QwenConnectionHandler { console.log( '[QwenAgentManager] Creating new session (letting CLI handle authentication)...', ); - await this.newSessionWithRetry( + const newSessionResult = await this.newSessionWithRetry( connection, workingDir, 3, authMethod, autoAuthenticate, ); + modelInfo = + extractModelInfoFromNewSessionResult(newSessionResult) || undefined; console.log('[QwenAgentManager] New session created successfully'); sessionCreated = true; } catch (sessionError) { @@ -99,7 +105,7 @@ export class QwenConnectionHandler { console.log(`\n========================================`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`========================================\n`); - return { sessionCreated, requiresAuth }; + return { sessionCreated, requiresAuth, modelInfo }; } /** @@ -115,15 +121,15 @@ export class QwenConnectionHandler { maxRetries: number, authMethod: string, autoAuthenticate: boolean, - ): Promise { + ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log( `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, ); - await connection.newSession(workingDir); + const res = await connection.newSession(workingDir); console.log('[QwenAgentManager] Session created successfully'); - return; + return res; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -155,11 +161,11 @@ export class QwenConnectionHandler { '[QwenAgentManager] newSessionWithRetry Authentication successful', ); // Retry immediately after successful auth - await connection.newSession(workingDir); + const res = await connection.newSession(workingDir); console.log( '[QwenAgentManager] Session created successfully after auth', ); - return; + return res; } catch (authErr) { console.error( '[QwenAgentManager] Re-authentication failed:', @@ -180,5 +186,7 @@ export class QwenConnectionHandler { await new Promise((resolve) => setTimeout(resolve, delay)); } } + + throw new Error('Session creation failed unexpectedly'); } } diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index aa844c455..06b7e739d 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -61,9 +61,20 @@ export interface SessionUpdateMeta { durationMs?: number | null; } +export type AcpMeta = Record; +export type ModelId = string; + export interface ModelInfo { + _meta?: AcpMeta | null; + description?: string | null; + modelId: ModelId; name: string; - contextLimit?: number | null; +} + +export interface SessionModelState { + _meta?: AcpMeta | null; + availableModels: ModelInfo[]; + currentModelId: ModelId; } export interface UserMessageChunkUpdate extends BaseSessionUpdate { diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts new file mode 100644 index 000000000..60aef8217 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { extractModelInfoFromNewSessionResult } from './acpModelInfo.js'; + +describe('extractModelInfoFromNewSessionResult', () => { + it('extracts from NewSessionResponse.models (SessionModelState)', () => { + expect( + extractModelInfoFromNewSessionResult({ + sessionId: 's', + models: { + currentModelId: 'qwen3-coder-plus', + availableModels: [ + { + modelId: 'qwen3-coder-plus', + name: 'Qwen3 Coder Plus', + description: null, + _meta: { contextLimit: 123 }, + }, + ], + }, + }), + ).toEqual({ + modelId: 'qwen3-coder-plus', + name: 'Qwen3 Coder Plus', + description: null, + _meta: { contextLimit: 123 }, + }); + }); + + it('skips invalid model entries and returns first valid one', () => { + expect( + extractModelInfoFromNewSessionResult({ + models: { + currentModelId: 'ok', + availableModels: [ + { name: '', modelId: '' }, + { name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } }, + ], + }, + }), + ).toEqual({ name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } }); + }); + + it('falls back to single `model` object', () => { + expect( + extractModelInfoFromNewSessionResult({ + model: { + name: 'Single', + modelId: 'single', + _meta: { contextLimit: 999 }, + }, + }), + ).toEqual({ + name: 'Single', + modelId: 'single', + _meta: { contextLimit: 999 }, + }); + }); + + it('falls back to legacy `modelInfo`', () => { + expect( + extractModelInfoFromNewSessionResult({ + modelInfo: { name: 'legacy' }, + }), + ).toEqual({ name: 'legacy', modelId: 'legacy' }); + }); + + it('returns null when missing', () => { + expect(extractModelInfoFromNewSessionResult({})).toBeNull(); + expect(extractModelInfoFromNewSessionResult(null)).toBeNull(); + }); +}); diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts new file mode 100644 index 000000000..9845de8e9 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AcpMeta, ModelInfo } from '../types/acpTypes.js'; + +const asMeta = (value: unknown): AcpMeta | null | undefined => { + if (value === null) { + return null; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as AcpMeta; + } + return undefined; +}; + +const normalizeModelInfo = (value: unknown): ModelInfo | null => { + if (!value || typeof value !== 'object') { + return null; + } + + const obj = value as Record; + const nameRaw = obj['name']; + const modelIdRaw = obj['modelId']; + const descriptionRaw = obj['description']; + + const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''; + const modelId = + typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0 + ? modelIdRaw.trim() + : name; + + if (!modelId || modelId.trim().length === 0 || !name) { + return null; + } + + const description = + typeof descriptionRaw === 'string' || descriptionRaw === null + ? descriptionRaw + : undefined; + + const metaFromWire = asMeta(obj['_meta']); + + // Back-compat: older implementations used `contextLimit` at the top-level. + const legacyContextLimit = obj['contextLimit']; + const contextLimit = + typeof legacyContextLimit === 'number' || legacyContextLimit === null + ? legacyContextLimit + : undefined; + + let mergedMeta: AcpMeta | null | undefined = metaFromWire; + if (typeof contextLimit !== 'undefined') { + if (mergedMeta === null) { + mergedMeta = { contextLimit }; + } else if (typeof mergedMeta === 'undefined') { + mergedMeta = { contextLimit }; + } else { + mergedMeta = { ...mergedMeta, contextLimit }; + } + } + + return { + modelId, + name, + ...(typeof description !== 'undefined' ? { description } : {}), + ...(typeof mergedMeta !== 'undefined' ? { _meta: mergedMeta } : {}), + }; +}; + +/** + * Extract model info from ACP `session/new` result. + * + * Per Agent Client Protocol draft schema, NewSessionResponse includes `models`. + * We also accept legacy shapes for compatibility. + */ +export const extractModelInfoFromNewSessionResult = ( + result: unknown, +): ModelInfo | null => { + if (!result || typeof result !== 'object') { + return null; + } + + const obj = result as Record; + + const models = obj['models']; + + // ACP draft: NewSessionResponse.models is a SessionModelState object. + if (models && typeof models === 'object' && !Array.isArray(models)) { + const state = models as Record; + const availableModels = state['availableModels']; + const currentModelId = state['currentModelId']; + if (Array.isArray(availableModels)) { + const normalizedModels = availableModels + .map(normalizeModelInfo) + .filter((m): m is ModelInfo => Boolean(m)); + if (normalizedModels.length > 0) { + if (typeof currentModelId === 'string' && currentModelId.length > 0) { + const selected = normalizedModels.find( + (m) => m.modelId === currentModelId, + ); + if (selected) { + return selected; + } + } + return normalizedModels[0]; + } + } + } + + // Legacy: some implementations returned `models` as a raw array. + if (Array.isArray(models)) { + for (const entry of models) { + const normalized = normalizeModelInfo(entry); + if (normalized) { + return normalized; + } + } + } + + // Some implementations may return a single model object. + const model = normalizeModelInfo(obj['model']); + if (model) { + return model; + } + + // Legacy: modelInfo on initialize; allow as a fallback. + const legacy = normalizeModelInfo(obj['modelInfo']); + if (legacy) { + return legacy; + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 318dbd2bd..bf3ecd5ed 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -46,6 +46,7 @@ import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; +import type { ModelInfo } from '../types/acpTypes.js'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, @@ -74,10 +75,7 @@ export const App: React.FC = () => { const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading - const [modelInfo, setModelInfo] = useState<{ - name: string; - contextLimit?: number | null; - } | null>(null); + const [modelInfo, setModelInfo] = useState(null); const [usageStats, setUsageStats] = useState(null); const messagesEndRef = useRef( null, @@ -175,16 +173,24 @@ export const App: React.FC = () => { } const modelName = - modelInfo?.name && typeof modelInfo.name === 'string' - ? modelInfo.name - : undefined; + modelInfo?.modelId && typeof modelInfo.modelId === 'string' + ? modelInfo.modelId + : modelInfo?.name && typeof modelInfo.name === 'string' + ? modelInfo.name + : undefined; const derivedLimit = modelName && modelName.length > 0 ? tokenLimit(modelName) : undefined; + const metaLimitRaw = modelInfo?._meta?.['contextLimit']; + const metaLimit = + typeof metaLimitRaw === 'number' || metaLimitRaw === null + ? metaLimitRaw + : undefined; + const limit = usageStats?.tokenLimit ?? - modelInfo?.contextLimit ?? + metaLimit ?? derivedLimit ?? DEFAULT_TOKEN_LIMIT; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index c601ef0a8..9995d095c 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -17,6 +17,7 @@ import type { } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; +import type { ModelInfo } from '../../types/acpTypes.js'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -125,9 +126,7 @@ interface UseWebViewMessagesProps { // Usage stats setter setUsageStats?: (stats: UsageStatsPayload | undefined) => void; // Model info setter - setModelInfo?: ( - info: { name: string; contextLimit?: number | null } | null, - ) => void; + setModelInfo?: (info: ModelInfo | null) => void; } /** @@ -154,10 +153,7 @@ export const useWebViewMessages = ({ // Track active long-running tool calls (execute/bash/command) so we can // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); - const modelInfoRef = useRef<{ - name: string; - contextLimit?: number | null; - } | null>(null); + const modelInfoRef = useRef(null); // Use ref to store callbacks to avoid useEffect dependency issues const handlersRef = useRef({ sessionManagement, @@ -256,13 +252,25 @@ export const useWebViewMessages = ({ } case 'modelInfo': { - const info = message.data as - | { name?: string; contextLimit?: number | null } - | undefined; - if (info && typeof info.name === 'string') { - const normalized = { - name: info.name, - contextLimit: info.contextLimit, + const info = message.data as Partial | undefined; + if ( + info && + typeof info.name === 'string' && + info.name.trim().length > 0 + ) { + const modelId = + typeof info.modelId === 'string' && info.modelId.trim().length > 0 + ? info.modelId.trim() + : info.name.trim(); + const normalized: ModelInfo = { + modelId, + name: info.name.trim(), + ...(typeof info.description !== 'undefined' + ? { description: info.description ?? null } + : {}), + ...(typeof info._meta !== 'undefined' + ? { _meta: info._meta } + : {}), }; modelInfoRef.current = normalized; handlers.setModelInfo?.(normalized);