agents: show per-turn model in agent-host session history (#313885)

* agents: show per-turn model in agent-host session history

Surface the model used for each response in agent-host chat sessions, like
extension-host Copilot CLI sessions do.

AHP already reports the model on each turn via Turn.usage?.model. The
workbench-side adapter (turnsToHistory) was ignoring this and stamping a
single session-level modelId on every request, with no per-response model
detail.

Refactor turnsToHistory to take a TurnModelLookup (id + display name
resolution) instead of a single modelId. AgentHostSessionHandler builds
the lookup using ILanguageModelsService and the session-type prefix, with
fallback to the session-level summary model when a turn has no usage.

Response history items now carry the display name in 'details', which
flows through to the per-response footer in the chat UI.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add integrated history-loading test for per-turn model display

Covers the active-turn restoration path through provideChatSessionContent
with a mix of completed turns (with/without usage.model) and an in-flight
activeTurn falling back to the session-level model.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Rob Lourens
2026-05-02 12:15:10 -07:00
committed by GitHub
parent 9c4ef87056
commit 882f02a7bd
4 changed files with 184 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@@ -59,8 +59,28 @@ function finalizeToolInvocation(invocation: Parameters<typeof rawFinalizeToolInv
return rawFinalizeToolInvocation(invocation, tc, URI.file('/'), undefined);
}
function turnsToHistory(backendSession: Parameters<typeof rawTurnsToHistory>[0], turns: Parameters<typeof rawTurnsToHistory>[1], participantId: Parameters<typeof rawTurnsToHistory>[2], modelId?: Parameters<typeof rawTurnsToHistory>[4]) {
return rawTurnsToHistory(backendSession, turns, participantId, undefined, modelId);
function turnsToHistory(backendSession: Parameters<typeof rawTurnsToHistory>[0], turns: Parameters<typeof rawTurnsToHistory>[1], participantId: Parameters<typeof rawTurnsToHistory>[2], lookup?: Parameters<typeof rawTurnsToHistory>[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<string, string>, fallbackRawModelId?: string): Parameters<typeof rawTurnsToHistory>[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<typeof rawActiveTurnToProgress>[0], activeTurn: Parameters<typeof rawActiveTurnToProgress>[1], connectionAuthority?: Parameters<typeof rawActiveTurnToProgress>[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,