Path: blob/main/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { URI } from '../../../../base/common/uri.js';6import { generateUuid } from '../../../../base/common/uuid.js';7import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js';8import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js';9import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js';10import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js';11import { buildSessionDbUri } from './fileEditTracker.js';1213function tryStringify(value: unknown): string | undefined {14try {15return JSON.stringify(value);16} catch {17return undefined;18}19}2021// ---- Minimal event shapes matching the SDK's SessionEvent union ---------22// Defined here so tests can construct events without importing the SDK.2324export interface ISessionEventToolStart {25type: 'tool.execution_start';26data: {27toolCallId: string;28toolName: string;29arguments?: unknown;30mcpServerName?: string;31mcpToolName?: string;32parentToolCallId?: string;33};34}3536export interface ISessionEventToolComplete {37type: 'tool.execution_complete';38data: {39toolCallId: string;40success: boolean;41result?: { content?: string };42error?: { message: string; code?: string };43isUserRequested?: boolean;44toolTelemetry?: unknown;45parentToolCallId?: string;46};47}4849export interface ISessionEventMessage {50type: 'assistant.message' | 'user.message';51data?: {52messageId?: string;53interactionId?: string;54content?: string;55toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[];56reasoningOpaque?: string;57reasoningText?: string;58encryptedContent?: string;59parentToolCallId?: string;60/**61* Origin of this message. The SDK sets this to a non-`'user'` value62* (e.g. `'skill-pdf'`) for messages it injects on behalf of a skill or63* other internal mechanism. We filter those out so they don't render64* as user turns.65*/66source?: string;67};68}6970/** Minimal event shape for `skill.invoked`, used to synthesize a tool-style render. */71export interface ISessionEventSkillInvoked {72type: 'skill.invoked';73id?: string;74data: {75name: string;76path?: string;77description?: string;78};79}8081export interface ISessionEventSubagentStarted {82type: 'subagent.started';83data: {84toolCallId: string;85agentName: string;86agentDisplayName: string;87agentDescription: string;88};89}9091/** Minimal event shape for session history mapping. */92export type ISessionEvent =93| ISessionEventToolStart94| ISessionEventToolComplete95| ISessionEventMessage96| ISessionEventSubagentStarted97| ISessionEventSkillInvoked98| { type: string; data?: unknown };99100/**101* Returns true if the event is a SDK-injected `user.message` that should not102* be shown to the user (e.g. skill-content injection).103*104* The SDK marks these via a non-`'user'` `source` field. Older sessions105* persisted before `source` existed will not be filtered; that is accepted106* leakage rather than guessed-at content sniffing.107*/108function isSyntheticUserMessage(event: ISessionEvent): boolean {109if (event.type !== 'user.message') {110return false;111}112const source = (event as ISessionEventMessage).data?.source;113return !!source && source.toLowerCase() !== 'user';114}115116// =============================================================================117// Single-pass turn builder118// =============================================================================119120/** Per-tool-call info captured from `tool.execution_start` and reused at `tool.execution_complete`. */121interface IToolStartInfo {122readonly toolName: string;123readonly displayName: string;124readonly invocationMessage: StringOrMarkdown;125readonly toolInput?: string;126readonly toolKind?: 'terminal' | 'subagent';127readonly language?: string;128readonly subagentAgentName?: string;129readonly subagentDescription?: string;130readonly parameters: Record<string, unknown> | undefined;131readonly parentToolCallId?: string;132}133134/** Subagent metadata seen via `subagent.started`, applied to the parent tool call's content at `tool.execution_complete`. */135interface ISubagentInfo {136readonly agentName: string;137readonly agentDisplayName: string;138readonly agentDescription?: string;139}140141/**142* Mutable per-turn state used while iterating events. The parent session143* has one builder; each subagent turn (one per `parentToolCallId`) has its144* own builder so inner events route there directly.145*/146interface ITurnBuilder {147readonly id: string;148readonly userMessage: { text: string };149readonly responseParts: ResponsePart[];150/** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */151readonly pendingTools: Map<string, IToolStartInfo>;152}153154function newTurnBuilder(id: string, text: string): ITurnBuilder {155return { id, userMessage: { text }, responseParts: [], pendingTools: new Map() };156}157158function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn {159return {160id: builder.id,161userMessage: builder.userMessage,162responseParts: builder.responseParts,163usage: undefined,164state,165};166}167168/**169* Maps raw SDK session events directly into agent-protocol {@link Turn}s170* for the parent session and any subagent child sessions, restoring stored171* file-edit metadata from the session database when available.172*173* Subagent inner events are routed to per-`parentToolCallId` turn builders174* so they appear under their own session view rather than polluting the175* parent transcript. Each subagent's tool calls are returned via176* {@link mapSessionEventsToTurns.subagentTurnsByToolCallId} so callers can177* expose `getSubagentMessages` cheaply.178*179* If `workingDirectory` is provided, redundant `cd <workingDirectory> &&`180* (or PowerShell equivalent) prefixes are stripped from shell tool181* commands so clients see the simplified form.182*/183export async function mapSessionEvents(184session: URI,185db: ISessionDatabase | undefined,186events: readonly ISessionEvent[],187workingDirectory?: URI,188): Promise<{ turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap<string, Turn[]> }> {189// First pass: collect tool-arg info and identify edit tool calls so we190// can batch-load their stored file edits before the second pass needs191// them at `tool.execution_complete` time.192const toolInfoByCallId = new Map<string, IToolStartInfo>();193const editToolCallIds: string[] = [];194for (const e of events) {195if (e.type !== 'tool.execution_start') {196continue;197}198const d = (e as ISessionEventToolStart).data;199if (isHiddenTool(d.toolName)) {200continue;201}202const rawArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined;203let parameters: Record<string, unknown> | undefined;204if (rawArgs) {205try { parameters = JSON.parse(rawArgs) as Record<string, unknown>; } catch { /* ignore */ }206}207// stripRedundantCdPrefix mutates `parameters` and signals via its208// return value. We re-stringify only when it changed something so209// `getToolInputString` sees the cleaned command line.210const cleaned = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined;211const toolArgs = cleaned ?? rawArgs;212const toolKind = getToolKind(d.toolName);213const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined;214const displayName = getToolDisplayName(d.toolName);215toolInfoByCallId.set(d.toolCallId, {216toolName: d.toolName,217displayName,218invocationMessage: getInvocationMessage(d.toolName, displayName, parameters),219toolInput: getToolInputString(d.toolName, parameters, toolArgs),220toolKind,221language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined,222subagentAgentName: subagentMeta?.agentName,223subagentDescription: subagentMeta?.description,224parameters,225parentToolCallId: d.parentToolCallId,226});227if (isEditTool(d.toolName)) {228editToolCallIds.push(d.toolCallId);229}230}231232// Pre-load stored file-edit metadata for all edit tool calls.233let storedEdits: Map<string, IFileEditRecord[]> | undefined;234if (db && editToolCallIds.length > 0) {235try {236const records = await db.getFileEdits(editToolCallIds);237if (records.length > 0) {238storedEdits = new Map();239for (const r of records) {240let list = storedEdits.get(r.toolCallId);241if (!list) {242list = [];243storedEdits.set(r.toolCallId, list);244}245list.push(r);246}247}248} catch {249// Database may not exist yet for new sessions — that's fine.250}251}252253const sessionUriStr = session.toString();254const turns: Turn[] = [];255256// Subagent state. Each subagent has its own active turn builder; only257// the most recent turn per subagent is built (subagents currently emit258// at most one turn per invocation).259const subagentBuilders = new Map<string, ITurnBuilder>();260const subagentTurns = new Map<string, Turn[]>();261const subagentInfoByToolCallId = new Map<string, ISubagentInfo>();262263let parentBuilder: ITurnBuilder | undefined;264265const flushSubagent = (parentToolCallId: string): void => {266const builder = subagentBuilders.get(parentToolCallId);267if (!builder) {268return;269}270subagentBuilders.delete(parentToolCallId);271if (builder.responseParts.length === 0) {272return;273}274const list = subagentTurns.get(parentToolCallId) ?? [];275list.push(finalizeTurn(builder, TurnState.Complete));276subagentTurns.set(parentToolCallId, list);277};278279const ensureSubagentBuilder = (parentToolCallId: string): ITurnBuilder => {280let builder = subagentBuilders.get(parentToolCallId);281if (!builder) {282builder = newTurnBuilder(generateUuid(), '');283subagentBuilders.set(parentToolCallId, builder);284}285return builder;286};287288const targetBuilderFor = (parentToolCallId: string | undefined): ITurnBuilder | undefined => {289if (parentToolCallId) {290return ensureSubagentBuilder(parentToolCallId);291}292return parentBuilder;293};294295for (const e of events) {296switch (e.type) {297case 'user.message': {298if (isSyntheticUserMessage(e)) {299continue;300}301const d = (e as ISessionEventMessage).data;302const messageId = d?.messageId ?? d?.interactionId ?? '';303const content = d?.content ?? '';304if (d?.parentToolCallId) {305// User messages with a parent tool call route into the306// subagent's transcript. They never start a new parent307// turn; subagents currently only see assistant messages308// in practice, but route conservatively.309const builder = ensureSubagentBuilder(d.parentToolCallId);310if (content) {311builder.responseParts.push({312kind: ResponsePartKind.Markdown,313id: generateUuid(),314content,315});316}317} else {318// A new top-level user message starts a new parent turn.319if (parentBuilder) {320turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled));321}322parentBuilder = newTurnBuilder(messageId, content);323}324break;325}326case 'assistant.message': {327const d = (e as ISessionEventMessage).data;328const messageId = d?.messageId ?? d?.interactionId ?? '';329const content = d?.content ?? '';330const reasoningText = d?.reasoningText;331const hasToolRequests = !!d?.toolRequests && d.toolRequests.length > 0;332const builder = targetBuilderFor(d?.parentToolCallId)333?? (parentBuilder = newTurnBuilder(messageId, ''));334if (reasoningText) {335builder.responseParts.push({336kind: ResponsePartKind.Reasoning,337id: generateUuid(),338content: reasoningText,339});340}341if (content) {342builder.responseParts.push({343kind: ResponsePartKind.Markdown,344id: generateUuid(),345content,346});347}348// A parent assistant message without further tool requests349// terminates the current parent turn (no more responses350// expected). Subagent turns are flushed at the parent's351// `tool.execution_complete` instead.352if (!d?.parentToolCallId && !hasToolRequests && builder === parentBuilder) {353turns.push(finalizeTurn(parentBuilder, TurnState.Complete));354parentBuilder = undefined;355}356break;357}358case 'subagent.started': {359const d = (e as ISessionEventSubagentStarted).data;360subagentInfoByToolCallId.set(d.toolCallId, {361agentName: d.agentName,362agentDisplayName: d.agentDisplayName,363agentDescription: d.agentDescription,364});365break;366}367case 'tool.execution_start': {368// Already collected in the first pass; no per-event work369// needed here. Hidden tools are filtered above.370break;371}372case 'tool.execution_complete': {373const d = (e as ISessionEventToolComplete).data;374const info = toolInfoByCallId.get(d.toolCallId);375if (!info) {376// Orphan complete (no matching start), or hidden tool.377continue;378}379toolInfoByCallId.delete(d.toolCallId);380const builder = targetBuilderFor(d.parentToolCallId);381if (!builder) {382// No active turn to attach this completion to.383continue;384}385const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, storedEdits, subagentInfoByToolCallId.get(d.toolCallId));386builder.responseParts.push(completedPart);387// When a parent tool call that spawned a subagent completes,388// flush the subagent's accumulated turn.389if (!d.parentToolCallId && subagentInfoByToolCallId.has(d.toolCallId)) {390flushSubagent(d.toolCallId);391}392break;393}394case 'skill.invoked': {395const skill = (e as ISessionEventSkillInvoked);396const synth = synthesizeSkillToolCall(skill.data, skill.id);397const builder = parentBuilder ?? (parentBuilder = newTurnBuilder(generateUuid(), ''));398builder.responseParts.push({399kind: ResponsePartKind.ToolCall,400toolCall: {401status: ToolCallStatus.Completed,402toolCallId: synth.toolCallId,403toolName: synth.toolName,404displayName: synth.displayName,405invocationMessage: synth.invocationMessage,406success: true,407pastTenseMessage: synth.pastTenseMessage,408confirmed: ToolCallConfirmationReason.NotNeeded,409} satisfies ToolCallCompletedState,410});411break;412}413default:414break;415}416}417418// Drain any unfinished turns.419if (parentBuilder) {420turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled));421parentBuilder = undefined;422}423for (const parentToolCallId of [...subagentBuilders.keys()]) {424flushSubagent(parentToolCallId);425}426427return { turns, subagentTurnsByToolCallId: subagentTurns };428}429430/**431* Builds a {@link ToolCallCompletedState}-shaped response part from an432* SDK `tool.execution_complete` event. Restores file-edit content433* references from `storedEdits` and merges subagent metadata when the434* tool call spawned a child session.435*/436function makeCompletedToolCallPart(437d: ISessionEventToolComplete['data'],438info: IToolStartInfo,439sessionUriStr: string,440storedEdits: Map<string, IFileEditRecord[]> | undefined,441subagent: ISubagentInfo | undefined,442): ResponsePart {443const toolOutput = d.error?.message ?? d.result?.content;444const content: ToolResultContent[] = [];445if (toolOutput !== undefined) {446content.push({ type: ToolResultContentType.Text, text: toolOutput });447}448449// Restore file edit content references from the database.450const edits = storedEdits?.get(d.toolCallId);451if (edits) {452for (const edit of edits) {453const beforeUri = edit.kind === 'rename' && edit.originalPath454? URI.file(edit.originalPath).toString()455: URI.file(edit.filePath).toString();456const afterUri = URI.file(edit.filePath).toString();457const hasBefore = edit.kind !== 'create';458const hasAfter = edit.kind !== 'delete';459content.push({460type: ToolResultContentType.FileEdit,461before: hasBefore ? {462uri: beforeUri,463content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') },464} : undefined,465after: hasAfter ? {466uri: afterUri,467content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') },468} : undefined,469diff: (edit.addedLines !== undefined || edit.removedLines !== undefined)470? { added: edit.addedLines, removed: edit.removedLines }471: undefined,472});473}474}475476if (subagent) {477content.push({478type: ToolResultContentType.Subagent,479resource: buildSubagentSessionUri(sessionUriStr, d.toolCallId),480title: subagent.agentDisplayName,481agentName: subagent.agentName,482description: subagent.agentDescription,483});484}485486const tc: ToolCallCompletedState = {487status: ToolCallStatus.Completed,488toolCallId: d.toolCallId,489toolName: info.toolName,490displayName: info.displayName,491invocationMessage: info.invocationMessage,492toolInput: info.toolInput,493success: d.success,494pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success),495content: content.length > 0 ? content : undefined,496error: d.error,497confirmed: ToolCallConfirmationReason.NotNeeded,498_meta: {499toolKind: info.toolKind,500language: info.language,501subagentDescription: info.subagentDescription,502subagentAgentName: info.subagentAgentName,503},504};505return { kind: ResponsePartKind.ToolCall, toolCall: tc };506}507508509