diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
index d442676d391..0c37f85e456 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
@@ -2473,7 +2473,7 @@ export class CopilotAgentSession extends Disposable {
turnId: this._turnId,
part: {
kind: ResponsePartKind.SystemNotification,
- content: notification.content,
+ content: notification.messageText,
},
});
return;
diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts
index 98e74e42da2..43adbcb4782 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts
@@ -8,8 +8,6 @@ import { softAssertNever } from '../../../../base/common/assert.js';
import { localize } from '../../../../nls.js';
export interface ICopilotSystemNotification {
- /** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */
- readonly content: string;
/** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */
readonly messageText: string;
/** Whether the runtime notification wakes the agent loop when it arrives while idle. */
@@ -30,7 +28,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
const description = kind.description;
const shellId = kind.shellId;
return {
- content,
messageText: description
? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description)
: shellId
@@ -41,7 +38,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
}
case 'agent_completed':
return {
- content,
messageText: kind.status === 'failed'
? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId)
: localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId),
@@ -49,19 +45,16 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
};
case 'agent_idle':
return {
- content,
messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId),
startsTurn: true,
};
case 'new_inbox_message':
return {
- content,
messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName),
startsTurn: false,
};
case 'instruction_discovered':
return {
- content,
messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath),
startsTurn: false,
};
diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
index 01326d7b641..c3fde85759d 100644
--- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
+++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
@@ -17,6 +17,7 @@ import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStat
import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js';
import { buildSessionDbUri } from '../shared/fileEditTracker.js';
import { getMediaMime } from '../../../../base/common/mime.js';
+import { buildCopilotSystemNotification } from './copilotSystemNotification.js';
function tryStringify(value: unknown): string | undefined {
try {
@@ -103,10 +104,10 @@ export interface IMapSessionEventsOptions {
readonly agent?: AgentSelection;
}
-function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection }): ITurnBuilder {
+function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection; origin?: MessageKind }): ITurnBuilder {
const message: Message = {
text,
- origin: { kind: MessageKind.User },
+ origin: { kind: options?.origin ?? MessageKind.User },
...(options?.attachments?.length ? { attachments: options.attachments } : {}),
...(options?.model ? { model: options.model } : {}),
...(options?.agent ? { agent: options.agent } : {}),
@@ -258,6 +259,7 @@ export async function mapSessionEvents(
let parentBuilder: ITurnBuilder | undefined;
let parentTurnState = TurnState.Cancelled;
let parentTurnAborted = false;
+ let rootAssistantTurnActive = false;
const flushParent = (): void => {
if (!parentBuilder) {
@@ -307,6 +309,16 @@ export async function mapSessionEvents(
for (const e of events) {
switch (e.type) {
+ case 'assistant.turn_start':
+ if (!e.agentId) {
+ rootAssistantTurnActive = true;
+ }
+ break;
+ case 'assistant.turn_end':
+ if (!e.agentId) {
+ rootAssistantTurnActive = false;
+ }
+ break;
case 'session.model_change': {
currentModel = { id: e.data.newModel };
break;
@@ -399,6 +411,22 @@ export async function mapSessionEvents(
}
break;
}
+ case 'system.notification': {
+ const notification = buildCopilotSystemNotification(e);
+ if (!notification) {
+ break;
+ }
+ if (rootAssistantTurnActive && parentBuilder) {
+ parentBuilder.responseParts.push({
+ kind: ResponsePartKind.SystemNotification,
+ content: notification.messageText,
+ });
+ } else if (notification.startsTurn) {
+ flushParent();
+ parentBuilder = newTurnBuilder(e.id, notification.messageText, { origin: MessageKind.SystemNotification });
+ }
+ break;
+ }
case 'subagent.started': {
const d = e.data;
subagentInfoByToolCallId.set(d.toolCallId, {
@@ -483,9 +511,12 @@ export async function mapSessionEvents(
const parentToolCallId = resolveParentToolCallId(e.agentId, undefined);
if (parentToolCallId) {
subagentTurnStates.set(parentToolCallId, TurnState.Cancelled);
- } else if (parentBuilder) {
- parentTurnState = TurnState.Cancelled;
- parentTurnAborted = true;
+ } else {
+ rootAssistantTurnActive = false;
+ if (parentBuilder) {
+ parentTurnState = TurnState.Cancelled;
+ parentTurnAborted = true;
+ }
}
break;
}
diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
index b989740dccd..e75159e0513 100644
--- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
+++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
@@ -2004,7 +2004,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' },
},
}), {
- content: 'Shell done',
messageText: '`sleep 6` completed',
startsTurn: true,
});
@@ -2016,7 +2015,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'shell_detached_completed', shellId: 'detached-a' },
},
}), {
- content: 'Detached done',
messageText: 'Shell `detached-a` completed',
startsTurn: true,
});
@@ -2028,7 +2026,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' },
},
}), {
- content: 'Agent done',
messageText: 'Background agent agent-a completed',
startsTurn: true,
});
@@ -2040,7 +2037,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'agent_completed', agentId: 'agent-b', agentType: 'task', status: 'failed' },
},
}), {
- content: 'Agent failed',
messageText: 'Background agent agent-b failed',
startsTurn: true,
});
@@ -2052,7 +2048,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' },
},
}), {
- content: 'Agent idle',
messageText: 'Background agent agent-a is complete',
startsTurn: true,
});
@@ -2064,7 +2059,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' },
},
}), {
- content: 'Inbox message',
messageText: 'New inbox message from sidekick',
startsTurn: false,
});
@@ -2076,7 +2070,6 @@ suite('CopilotAgentSession', () => {
kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' },
},
}), {
- content: 'Discovered instruction',
messageText: 'Instruction discovered: AGENTS.md from packages/billing/',
startsTurn: false,
});
@@ -2150,7 +2143,7 @@ suite('CopilotAgentSession', () => {
turnId: 'turn-active',
part: {
kind: ResponsePartKind.SystemNotification,
- content: 'Agent "agent-a" has finished processing and is now idle.',
+ content: 'Background agent agent-a is complete',
},
});
});
@@ -2177,8 +2170,8 @@ suite('CopilotAgentSession', () => {
assert.deepStrictEqual(getActions(signals)
.filter(action => action.type === ActionType.ChatResponsePart)
.map(action => action.part), [
- { kind: ResponsePartKind.SystemNotification, content: 'Inbox from sidekick' },
- { kind: ResponsePartKind.SystemNotification, content: 'Discovered instruction' },
+ { kind: ResponsePartKind.SystemNotification, content: 'New inbox message from sidekick' },
+ { kind: ResponsePartKind.SystemNotification, content: 'Instruction discovered: AGENTS.md from packages/billing/' },
]);
});
diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts
index bf4b1615bda..02935d7eb3a 100644
--- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts
+++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import type { Attachment, SessionEvent, ToolExecutionCompleteContent } from '@github/copilot-sdk';
+import type { Attachment, SessionEvent, SessionEventPayload, ToolExecutionCompleteContent } from '@github/copilot-sdk';
// =============================================================================
// Minimal session-event shapes for tests
@@ -106,6 +106,21 @@ export interface ISessionEventAbort {
};
}
+export interface ISessionEventAssistantTurn {
+ type: 'assistant.turn_start' | 'assistant.turn_end';
+ agentId?: string;
+ data: {
+ turnId: string;
+ interactionId?: string;
+ };
+}
+
+export interface ISessionEventSystemNotification {
+ type: 'system.notification';
+ id?: string;
+ data: SessionEventPayload<'system.notification'>['data'];
+}
+
/** Minimal event shape for session history mapping. */
export type ISessionEvent =
| ISessionEventToolStart
@@ -114,6 +129,8 @@ export type ISessionEvent =
| ISessionEventSubagentStarted
| ISessionEventSkillInvoked
| ISessionEventAbort
+ | ISessionEventAssistantTurn
+ | ISessionEventSystemNotification
| { type: string; data?: unknown };
/**
diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
index fb5fd929d06..3245bf697d1 100644
--- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
+++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
@@ -6,7 +6,7 @@
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { AgentSession } from '../../common/agentService.js';
-import { MessageAttachmentKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type ToolCallResponsePart } from '../../common/state/sessionState.js';
+import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart } from '../../common/state/sessionState.js';
import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js';
import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js';
@@ -16,8 +16,8 @@ suite('mapSessionEvents — history replay', () => {
const session = AgentSession.uri('copilot', 'test-session');
- function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: string }> {
- return parts.map(p => p.kind === ResponsePartKind.Markdown ? { kind: p.kind, content: p.content } : { kind: p.kind });
+ function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: StringOrMarkdown }> {
+ return parts.map(p => p.kind === ResponsePartKind.Markdown || p.kind === ResponsePartKind.SystemNotification ? { kind: p.kind, content: p.content } : { kind: p.kind });
}
test('task_complete with a summary renders as a markdown part, not a tool call', async () => {
@@ -235,6 +235,109 @@ suite('mapSessionEvents — history replay', () => {
]);
});
+ test('restores a system notification inside an assistant turn as a response part', async () => {
+ const events: ISessionEvent[] = [
+ { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Wait for the background command' } },
+ { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } },
+ {
+ type: 'system.notification',
+ id: 'notification-event',
+ data: {
+ content: '\nShell command completed\n',
+ kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' },
+ },
+ },
+ { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'Reading the output now.', toolRequests: [] } },
+ { type: 'assistant.turn_end', data: { turnId: '0' } },
+ ];
+
+ const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events));
+
+ assert.deepStrictEqual(turns.map(turn => ({
+ id: turn.id,
+ message: turn.message,
+ state: turn.state,
+ parts: partKinds(turn.responseParts),
+ })), [{
+ id: 'user-event',
+ message: { text: 'Wait for the background command', origin: { kind: MessageKind.User } },
+ state: TurnState.Complete,
+ parts: [
+ { kind: ResponsePartKind.SystemNotification, content: '`sleep 6` completed' },
+ { kind: ResponsePartKind.Markdown, content: 'Reading the output now.' },
+ ],
+ }]);
+ });
+
+ test('restores an idle system notification as a system-initiated turn', async () => {
+ const events: ISessionEvent[] = [
+ { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Start the background agent' } },
+ { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } },
+ { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'The background agent is running.', toolRequests: [] } },
+ { type: 'assistant.turn_end', data: { turnId: '0' } },
+ {
+ type: 'system.notification',
+ id: 'notification-event',
+ data: {
+ content: '\nAgent completed\n',
+ kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' },
+ },
+ },
+ { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-2' } },
+ { type: 'assistant.message', data: { interactionId: 'interaction-2', content: 'Reading the background agent result.', toolRequests: [] } },
+ { type: 'assistant.turn_end', data: { turnId: '0' } },
+ ];
+
+ const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events));
+
+ assert.deepStrictEqual(turns.map(turn => ({
+ id: turn.id,
+ message: turn.message,
+ state: turn.state,
+ parts: partKinds(turn.responseParts),
+ })), [
+ {
+ id: 'user-event',
+ message: { text: 'Start the background agent', origin: { kind: MessageKind.User } },
+ state: TurnState.Complete,
+ parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent is running.' }],
+ },
+ {
+ id: 'notification-event',
+ message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } },
+ state: TurnState.Complete,
+ parts: [{ kind: ResponsePartKind.Markdown, content: 'Reading the background agent result.' }],
+ },
+ ]);
+ });
+
+ test('does not restore a passive notification outside an assistant turn', async () => {
+ const events: ISessionEvent[] = [
+ { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Check for instructions' } },
+ { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } },
+ { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'No new instructions.', toolRequests: [] } },
+ { type: 'assistant.turn_end', data: { turnId: '0' } },
+ {
+ type: 'system.notification',
+ id: 'notification-event',
+ data: {
+ content: '\nInstruction discovered\n',
+ kind: { type: 'instruction_discovered', sourcePath: 'AGENTS.md', triggerFile: 'src/index.ts', triggerTool: 'view', description: 'Workspace instructions' },
+ },
+ },
+ ];
+
+ const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events));
+
+ assert.deepStrictEqual(turns.map(turn => ({
+ id: turn.id,
+ parts: partKinds(turn.responseParts),
+ })), [{
+ id: 'user-event',
+ parts: [{ kind: ResponsePartKind.Markdown, content: 'No new instructions.' }],
+ }]);
+ });
+
test('synthetic user messages do not start a new turn', async () => {
const events: ISessionEvent[] = [
{ type: 'user.message', id: 'user-event-1', data: { interactionId: 'interaction-1', content: 'Use the skill' } },
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 1caaada8b84..f6b287786ff 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts
@@ -88,7 +88,7 @@ import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferen
import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js';
import { toolDataToDefinition } from './agentHostToolUtils.js';
import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js';
-import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
+import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, systemNotificationToChatPart, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js';
export { toolDataToDefinition };
@@ -138,6 +138,7 @@ interface IObserveTurnOptions {
readonly cancellationToken: CancellationToken;
readonly adoptInvocations?: ReadonlyMap;
readonly seedEmittedLengths?: ReadonlyMap;
+ readonly initialResponsePartCount?: number;
readonly onTurnEnded?: (lastTurn: Turn | undefined) => void;
readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void;
/**
@@ -858,6 +859,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
const isNewSession = this._isNewSessionResource(sessionResource);
const history: IChatSessionHistoryItem[] = [];
let initialProgress: IChatProgress[] | undefined;
+ let initialResponsePartCount = 0;
let activeTurnId: string | undefined;
let sessionTitle: string | undefined;
let draftInputState: ISerializableChatModelInputState | undefined;
@@ -933,6 +935,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
details: lookup.toResponseDetails(activeRawModelId, sessionState.activeTurn.usage),
});
initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn, this._config.connectionAuthority);
+ initialResponsePartCount = sessionState.activeTurn.responseParts.length;
// Enrich usage entries with the actual model so the
// context-usage widget resolves the right context window
// on reconnection (same enrichment as _observeTurn).
@@ -1043,7 +1046,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
// If reconnecting to an active turn, wire up an ongoing state listener
// to stream new progress into the session's progressObs.
if (activeTurnId && initialProgress !== undefined) {
- this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress);
+ this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress, initialResponsePartCount);
}
// For existing sessions, start watching for server-initiated turns
@@ -1850,6 +1853,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
case ResponsePartKind.ToolCall:
this._setupToolCallPart(part$ as IObservable, partStore, opts, subagentContext);
break;
+ case ResponsePartKind.SystemNotification:
+ // System notifications don't have an id, so we have to identify it by index
+ if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) {
+ const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority);
+ if (progress) {
+ opts.sink([progress]);
+ }
+ }
+ break;
}
},
));
@@ -3225,6 +3237,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
turnId: string,
chatSession: AgentHostChatSession,
initialProgress: IChatProgress[],
+ initialResponsePartCount: number,
): void {
const sessionKey = backendSession.toString();
const chatURI = this._getChatURI(chatSession.sessionResource);
@@ -3263,6 +3276,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
cancellationToken: cts.token,
adoptInvocations,
seedEmittedLengths,
+ initialResponsePartCount,
onTurnEnded: () => {
chatSession.complete();
reconnectStore.dispose();
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 94cee9012a2..d34c23ed30a 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts
@@ -121,12 +121,12 @@ export function isSubagentToolName(toolName: string): boolean {
return SUBAGENT_TOOL_NAMES.has(toolName);
}
-function systemNotificationToProgress(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined {
+export function systemNotificationToChatPart(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined {
if (!content) {
return undefined;
}
const value = stringOrMarkdownToString(content, connectionAuthority);
- return { kind: 'progressMessage', content: typeof value === 'string' ? new MarkdownString(value) : value };
+ return { kind: 'systemNotification', content: typeof value === 'string' ? new MarkdownString(value) : value };
}
/**
@@ -385,7 +385,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part
break;
case ResponsePartKind.SystemNotification:
{
- const progress = systemNotificationToProgress(rp.content, connectionAuthority);
+ const progress = systemNotificationToChatPart(rp.content, connectionAuthority);
if (progress) {
parts.push(progress);
}
@@ -668,7 +668,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur
}
case ResponsePartKind.SystemNotification:
{
- const progress = systemNotificationToProgress(rp.content, connectionAuthority);
+ const progress = systemNotificationToChatPart(rp.content, connectionAuthority);
if (progress) {
parts.push(progress);
}
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts
new file mode 100644
index 00000000000..92f4e85b25d
--- /dev/null
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts
@@ -0,0 +1,32 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Codicon } from '../../../../../../base/common/codicons.js';
+import { Disposable } from '../../../../../../base/common/lifecycle.js';
+import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
+import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js';
+import { IChatSystemNotificationPart } from '../../../common/chatService/chatService.js';
+import { IChatRendererContent } from '../../../common/model/chatViewModel.js';
+import { IChatContentPart } from './chatContentParts.js';
+import { ChatProgressSubPart } from './chatProgressContentPart.js';
+
+export class ChatSystemNotificationContentPart extends Disposable implements IChatContentPart {
+ readonly domNode: HTMLElement;
+
+ constructor(
+ private readonly notification: IChatSystemNotificationPart,
+ renderer: IMarkdownRenderer,
+ @IInstantiationService instantiationService: IInstantiationService,
+ ) {
+ super();
+
+ const rendered = this._register(renderer.render(notification.content));
+ this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined)).domNode;
+ }
+
+ hasSameContent(other: IChatRendererContent): boolean {
+ return other.kind === 'systemNotification' && other.content.value === this.notification.content.value;
+ }
+}
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
index 5b2406da2a6..0ad16aeb913 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
@@ -91,11 +91,12 @@ import { ChatMcpAuthenticationContentPart } from './chatContentParts/chatMcpAuth
import { ChatMcpServersStartingContentPart } from './chatContentParts/chatMcpServersStartingContentPart.js';
import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js';
import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js';
-import { ChatProgressContentPart, ChatProgressSubPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js';
+import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js';
import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js';
import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js';
import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js';
import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js';
+import { ChatSystemNotificationContentPart } from './chatContentParts/chatSystemNotificationContentPart.js';
import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js';
import { ChatThinkingContentPart, getEffectiveThinkingDisplayMode } from './chatContentParts/chatThinkingContentPart.js';
import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js';
@@ -1652,13 +1653,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer;
progress: (IChatWarningMessage | IChatContentReference)[];
@@ -1341,6 +1346,7 @@ export type IChatProgress =
| IChatContentInlineReference
| IChatCodeCitation
| IChatProgressMessage
+ | IChatSystemNotificationPart
| IChatTask
| IChatTaskResult
| IChatCommandButton
diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts
index 27ac5249290..6efac954fa1 100644
--- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts
+++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts
@@ -31,7 +31,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo
import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js';
import { migrateLegacyTerminalToolSpecificData } from '../chat.js';
import { ChatPerfMark, markChat } from '../chatPerf.js';
-import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatPlanReview, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js';
+import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatInfoMessage, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPlanReview, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatSystemNotificationPart, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js';
import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js';
import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js';
import { ChatPlanReviewData } from './chatProgressTypes/chatPlanReviewData.js';
@@ -191,6 +191,7 @@ export type IChatProgressHistoryResponseContent =
| IChatMultiDiffDataSerialized
| IChatContentInlineReference
| IChatProgressMessage
+ | IChatSystemNotificationPart
| IChatCommandButton
| IChatWarningMessage
| IChatInfoMessage
@@ -625,6 +626,9 @@ class AbstractResponse implements IResponse {
case 'autoModeResolution':
// Ignore
continue;
+ case 'systemNotification':
+ segment = { text: part.content.value, isBlock: true };
+ break;
case 'toolInvocation':
case 'toolInvocationSerialized':
// Include tool invocations in the copy text
diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts
index 272b102deec..8d2b2276bd5 100644
--- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts
+++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts
@@ -76,6 +76,7 @@ const responsePartSchema = Adapt.v {
assert.strictEqual(totalContent, 'hello world');
}));
+ test('system notification response parts become live system notifications', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
+ const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables);
+
+ fire({
+ type: 'chat/responsePart',
+ session,
+ turnId,
+ part: { kind: ResponsePartKind.SystemNotification, content: 'Background command completed' },
+ } as ChatAction);
+ fire({ type: 'chat/turnComplete', session, turnId } as ChatAction);
+ await turnPromise;
+
+ const notifications = collected.flat().filter(part => part.kind === 'systemNotification');
+ assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']);
+ }));
+
test('live turn marks chat session complete after turnComplete', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
@@ -6319,7 +6336,7 @@ suite('AgentHostChatContribution', () => {
suite('reconnection to active turn', () => {
- function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string }>): SeededSessionState {
+ function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string; systemNotification: string }>): SeededSessionState {
const summary: SessionSummary = {
resource: sessionUri,
provider: 'copilot',
@@ -6333,6 +6350,9 @@ suite('AgentHostChatContribution', () => {
if (reasoningText) {
activeTurnParts.push({ kind: ResponsePartKind.Reasoning as const, id: 'reasoning-1', content: reasoningText });
}
+ if (overrides?.systemNotification) {
+ activeTurnParts.push({ kind: ResponsePartKind.SystemNotification as const, content: overrides.systemNotification });
+ }
activeTurnParts.push({ kind: ResponsePartKind.Markdown as const, id: 'md-active', content: overrides?.streamingText ?? 'Partial response so far' });
return {
...createSessionState(summary),
@@ -6397,6 +6417,21 @@ suite('AgentHostChatContribution', () => {
assert.strictEqual(markdownPart!.content.value, 'Partial response so far');
});
+ test('does not duplicate system notification progress when reconnecting', async () => {
+ const { sessionHandler, agentHostService } = createContribution(disposables);
+ const sessionUri = AgentSession.uri('copilot', 'reconnect-system-notification');
+ agentHostService.sessionStates.set(sessionUri.toString(), makeSessionStateWithActiveTurn(sessionUri.toString(), {
+ systemNotification: 'Background command completed',
+ }));
+
+ const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/reconnect-system-notification' });
+ const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None);
+ disposables.add(toDisposable(() => session.dispose()));
+
+ const notifications = (session.progressObs?.get() ?? []).filter(part => part.kind === 'systemNotification');
+ assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']);
+ });
+
test('provides interruptActiveResponseCallback when reconnecting', async () => {
const { sessionHandler, agentHostService } = createContribution(disposables);
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 910bf34f908..515f4aa1fef 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
@@ -9,7 +9,7 @@ import { URI } from '../../../../../../base/common/uri.js';
import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js';
-import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js';
+import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js';
import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js';
import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas, formatTurnResponseDetails } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js';
@@ -152,7 +152,7 @@ suite('stateToProgressAdapter', () => {
assert.strictEqual(history[0].systemInitiatedLabel, undefined);
});
- test('system notification response part restores as progress message', () => {
+ test('system notification response part restores as system notification', () => {
const turn = createTurn({
responseParts: [{ kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }],
});
@@ -161,8 +161,9 @@ suite('stateToProgressAdapter', () => {
const response = history[1];
assert.strictEqual(response.type, 'response');
if (response.type !== 'response') { return; }
- const progress = response.parts[0] as IChatProgressMessage;
- assert.strictEqual(progress.kind, 'progressMessage');
+ const progress = response.parts[0];
+ assert.strictEqual(progress.kind, 'systemNotification');
+ if (progress.kind !== 'systemNotification') { return; }
assert.strictEqual(progress.content.value, 'Shell command completed');
});
@@ -1261,13 +1262,14 @@ suite('stateToProgressAdapter', () => {
assert.strictEqual((result[0] as IChatMarkdownContent).content.value, 'Hello world');
});
- test('produces progress message for system notification', () => {
+ test('produces system notification for system notification response part', () => {
const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([
{ kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' },
]), undefined);
assert.strictEqual(result.length, 1);
- assert.strictEqual(result[0].kind, 'progressMessage');
- assert.strictEqual((result[0] as IChatProgressMessage).content.value, 'Shell command completed');
+ assert.strictEqual(result[0].kind, 'systemNotification');
+ if (result[0].kind !== 'systemNotification') { return; }
+ assert.strictEqual(result[0].content.value, 'Shell command completed');
});
test('produces thinking progress for reasoning', () => {
diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts
new file mode 100644
index 00000000000..54d20bc00be
--- /dev/null
+++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts
@@ -0,0 +1,47 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import { IRenderedMarkdown, renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js';
+import { mainWindow } from '../../../../../../../base/browser/window.js';
+import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js';
+import { DisposableStore } from '../../../../../../../base/common/lifecycle.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
+import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js';
+import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
+import { ChatSystemNotificationContentPart } from '../../../../browser/widget/chatContentParts/chatSystemNotificationContentPart.js';
+
+suite('ChatSystemNotificationContentPart', () => {
+ const store = ensureNoDisposablesAreLeakedInTestSuite();
+
+ test('renders persistent checked notification content', () => {
+ const disposables = store.add(new DisposableStore());
+ const instantiationService = workbenchInstantiationService(undefined, disposables);
+ const renderer: IMarkdownRenderer = {
+ render: (markdown: IMarkdownString): IRenderedMarkdown => {
+ const element = mainWindow.document.createElement('div');
+ element.textContent = renderAsPlaintext(markdown);
+ return { element, dispose: () => { } };
+ },
+ };
+ const part = disposables.add(instantiationService.createInstance(
+ ChatSystemNotificationContentPart,
+ { kind: 'systemNotification', content: new MarkdownString('Background command completed') },
+ renderer,
+ ));
+
+ assert.deepStrictEqual({
+ text: part.domNode.textContent,
+ hasCheck: !!part.domNode.querySelector('.codicon-check'),
+ sameContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }),
+ differentContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Different') }),
+ }, {
+ text: 'Background command completed',
+ hasCheck: true,
+ sameContent: true,
+ differentContent: false,
+ });
+ });
+});
diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts
index dd7d97e8844..10408fa892e 100644
--- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts
+++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts
@@ -407,6 +407,20 @@ suite('Response', () => {
await assertSnapshot(response.value);
});
+ test('system notification remains distinct from later response content', () => {
+ const response = store.add(new Response([]));
+ response.updateContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') });
+ response.updateContent({ kind: 'markdownContent', content: new MarkdownString('Finished processing output.') });
+
+ assert.deepStrictEqual({
+ kinds: response.value.map(part => part.kind),
+ text: response.toString(),
+ }, {
+ kinds: ['systemNotification', 'markdownContent'],
+ text: 'Background command completed\n\nFinished processing output.',
+ });
+ });
+
test('inline reference', async () => {
const response = store.add(new Response([]));
response.updateContent({ content: new MarkdownString('text before '), kind: 'markdownContent' });