diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index de4510c3fea..c30278287db 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -40,11 +40,12 @@ import { IChatEditingService } from '../../../common/editing/chatEditingService. import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -377,6 +378,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, ) { super(); this._config = config; @@ -494,8 +496,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const sessionState = this._getSessionState(resolvedSession.toString()); if (sessionState) { - const modelId = this._toLanguageModelId(sessionResource, sessionState.summary.model?.id); - history.push(...turnsToHistory(resolvedSession, sessionState.turns, this._config.agentId, this._config.connectionAuthority, modelId)); + const fallbackRawModelId = sessionState.summary.model?.id; + const lookup = this._createTurnModelLookup(sessionResource, fallbackRawModelId); + history.push(...turnsToHistory(resolvedSession, sessionState.turns, this._config.agentId, this._config.connectionAuthority, lookup)); // Enrich history with inner tool calls from subagent // child sessions. Subscribes to each child session so @@ -518,11 +521,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // progressObs for live streaming. if (sessionState.activeTurn) { activeTurnId = sessionState.activeTurn.id; + const activeRawModelId = sessionState.activeTurn.usage?.model ?? fallbackRawModelId; history.push({ type: 'request', prompt: sessionState.activeTurn.userMessage.text, participant: this._config.agentId, - modelId, + modelId: lookup.toLanguageModelId(activeRawModelId), }); history.push({ type: 'response', @@ -2363,6 +2367,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return rawModelId.startsWith(prefix) ? rawModelId : `${prefix}${rawModelId}`; } + /** + * Builds a per-turn model lookup that namespaces raw AHP model ids into + * chat-layer language-model ids and resolves human-readable display + * names via the registered language-model providers (so the chat UI's + * per-response footer can show e.g. "Claude Opus 4.7" instead of the + * raw model id). `fallbackRawModelId` is used when a turn's + * `usage?.model` is not yet set (e.g. older sessions or turns that + * never reported usage). + */ + private _createTurnModelLookup(sessionResource: URI, fallbackRawModelId: string | undefined): TurnModelLookup { + const resolveRaw = (rawModelId: string | undefined): string | undefined => rawModelId ?? fallbackRawModelId; + return { + toLanguageModelId: (rawModelId) => this._toLanguageModelId(sessionResource, resolveRaw(rawModelId)), + toModelDisplayName: (rawModelId) => { + const modelId = this._toLanguageModelId(sessionResource, resolveRaw(rawModelId)); + if (!modelId) { + return undefined; + } + return this._languageModelsService.lookupLanguageModel(modelId)?.name; + }, + }; + } + private _resolveRequestedWorkingDirectory(sessionResource: URI): URI | undefined { return this._config.resolveWorkingDirectory?.(sessionResource) ?? this._workingDirectoryResolver.resolve(sessionResource) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index bcd70315636..edd0cd0cb08 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -95,11 +95,35 @@ export function getTerminalContentUri(content: ToolResultContent[] | undefined): } /** - * Converts completed turns from the protocol state into session history items. + * Resolves a raw per-turn model id (as it appears on `UsageInfo.model`) into + * the chat layer's namespaced language-model id and a human-readable display + * name. Both halves are independent: the id flows onto request history items + * (so the input picker shows the model that ran), while the display name + * flows onto response history items as `details` (so the response footer + * shows the model that produced it). */ -export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string | undefined, modelId?: string): IChatSessionHistoryItem[] { +export interface TurnModelLookup { + /** Returns the chat-layer namespaced model id for a raw AHP model id. */ + toLanguageModelId(rawModelId: string | undefined): string | undefined; + /** Returns the human-readable display name, or undefined if unknown. */ + toModelDisplayName(rawModelId: string | undefined): string | undefined; +} + +/** + * Converts completed turns from the protocol state into session history items. + * + * Per turn, prefers `turn.usage?.model` so each request/response pair shows + * the model that actually ran, even if the user changed models mid-session. + * The `lookup` callback is responsible for any session-level fallback (e.g. + * `summary.model?.id` when usage hasn't reported a model yet). + */ +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string | undefined, lookup?: TurnModelLookup): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { + const rawModelId = turn.usage?.model; + const modelId = lookup?.toLanguageModelId(rawModelId); + const modelName = lookup?.toModelDisplayName(rawModelId); + // Request history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId }); @@ -141,7 +165,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part parts.push({ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${turn.error.errorType}) ${turn.error.message}`) }); } - history.push({ type: 'response', parts, participant: participantId }); + history.push({ type: 'response', parts, participant: participantId, details: modelName }); } return history; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 63ca7763090..62bf85c00bd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -28,7 +28,7 @@ import { ChatAgentLocation } from '../../../common/constants.js'; import { IChatService, IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -376,6 +376,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(ILanguageModelsService, { deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }), + lookupLanguageModel: () => undefined, }); instantiationService.stub(IConfigurationService, { onDidChangeConfiguration: Event.None, @@ -1733,6 +1734,62 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(session.history.length, 0); }); + + test('history requests get per-turn modelId from usage, with active turn falling back to session model', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const sessionUri = AgentSession.uri('copilot', 'sess-models'); + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ + resource: sessionUri.toString(), provider: 'copilot', title: 'Test', + status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + model: { id: 'sonnet-4.6' }, + }), + lifecycle: SessionLifecycle.Ready, + turns: [ + { + id: 'turn-1', + userMessage: { text: 'Q1' }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-1', content: 'A1' }], + usage: { model: 'opus-4.7' }, + state: TurnState.Complete, + }, + { + id: 'turn-2', + userMessage: { text: 'Q2' }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-2', content: 'A2' }], + usage: undefined, + state: TurnState.Complete, + }, + ], + activeTurn: { + id: 'turn-active', + userMessage: { text: 'Q3' }, + responseParts: [], + usage: undefined, + }, + }); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/sess-models' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + const requests = session.history.filter((h): h is IChatSessionRequestHistoryItem => h.type === 'request'); + assert.deepStrictEqual( + requests.map(r => ({ prompt: r.prompt, modelId: r.modelId })), + [ + { prompt: 'Q1', modelId: 'agent-host-copilot:opus-4.7' }, + { prompt: 'Q2', modelId: 'agent-host-copilot:sonnet-4.6' }, + { prompt: 'Q3', modelId: 'agent-host-copilot:sonnet-4.6' }, + ], + ); + + const activeResponse = session.history[session.history.length - 1]; + assert.strictEqual(activeResponse.type, 'response'); + if (activeResponse.type === 'response') { + assert.strictEqual(activeResponse.parts.length, 0); + } + }); }); // ---- Tool invocation rendering ----------------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index ad66f2a97f3..ef642f71173 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -59,8 +59,28 @@ function finalizeToolInvocation(invocation: Parameters[0], turns: Parameters[1], participantId: Parameters[2], modelId?: Parameters[4]) { - return rawTurnsToHistory(backendSession, turns, participantId, undefined, modelId); +function turnsToHistory(backendSession: Parameters[0], turns: Parameters[1], participantId: Parameters[2], lookup?: Parameters[4]) { + return rawTurnsToHistory(backendSession, turns, participantId, undefined, lookup); +} + +/** + * Builds a fake {@link TurnModelLookup} that namespaces ids with a fixed + * prefix and returns display names from a static map. `fallbackRawModelId` + * mirrors the real handler's "use summary.model when usage hasn't reported + * yet" behavior. + */ +function makeLookup(prefix: string, displayNames: Record, fallbackRawModelId?: string): Parameters[4] { + const resolveRaw = (raw: string | undefined): string | undefined => raw ?? fallbackRawModelId; + return { + toLanguageModelId: (raw) => { + const r = resolveRaw(raw); + return r ? `${prefix}${r}` : undefined; + }, + toModelDisplayName: (raw) => { + const r = resolveRaw(raw); + return r ? displayNames[r] : undefined; + }, + }; } function activeTurnToProgress(sessionResource: Parameters[0], activeTurn: Parameters[1], connectionAuthority?: Parameters[2]) { @@ -110,12 +130,57 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(serialized.isComplete, true); }); + test('per-turn model id and display name flow from usage.model', () => { + const turn1 = createTurn({ + id: 'turn-1', + userMessage: { text: 'first' }, + usage: { model: 'gpt-5' }, + }); + const turn2 = createTurn({ + id: 'turn-2', + userMessage: { text: 'second' }, + usage: { model: 'opus-4.7' }, + }); + + const lookup = makeLookup('agent-host-copilot:', { 'gpt-5': 'GPT-5', 'opus-4.7': 'Claude Opus 4.7' }); + const history = turnsToHistory(URI.file('/'), [turn1, turn2], 'p', lookup); + + assert.deepStrictEqual( + history.map(h => h.type === 'request' + ? { type: h.type, modelId: h.modelId } + : { type: h.type, details: h.details }), + [ + { type: 'request', modelId: 'agent-host-copilot:gpt-5' }, + { type: 'response', details: 'GPT-5' }, + { type: 'request', modelId: 'agent-host-copilot:opus-4.7' }, + { type: 'response', details: 'Claude Opus 4.7' }, + ], + ); + }); + + test('falls back to session-level model when turn has no usage.model', () => { + const turn = createTurn({ userMessage: { text: 'first' } }); + const lookup = makeLookup('agent-host-copilot:', { 'gpt-5': 'GPT-5' }, 'gpt-5'); + const history = turnsToHistory(URI.file('/'), [turn], 'p', lookup); + + assert.deepStrictEqual( + history.map(h => h.type === 'request' + ? { type: h.type, modelId: h.modelId } + : { type: h.type, details: h.details }), + [ + { type: 'request', modelId: 'agent-host-copilot:gpt-5' }, + { type: 'response', details: 'GPT-5' }, + ], + ); + }); + test('request history includes restored model id', () => { const turn = createTurn({ userMessage: { text: 'Use restored model' }, }); - const history = turnsToHistory(URI.file('/'), [turn], 'participant-1', 'agent-host-copilot:gpt-5'); + const lookup = makeLookup('agent-host-copilot:', {}, 'gpt-5'); + const history = turnsToHistory(URI.file('/'), [turn], 'participant-1', lookup); assert.deepStrictEqual(history[0], { id: turn.id,