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