mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-11 14:04:49 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 -----------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user