chat: render Agent Host system notifications consistently (#323382)

* agentHost: restore system notifications within turns

Persist system-initiated notification boundaries and map unmarked SDK notifications back to response parts during history reconstruction. Coordinate boundary writes with replay so restored transcripts match live behavior.

(Written by Copilot)

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

* agentHost: stream system notifications during active turns

Forward system notification response parts through the live turn observer, while avoiding duplicate progress when reconnecting to an active turn. Remove the unnecessary history/database workaround from the previous commit.

(Written by Copilot)

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

* agentHost: simplify notification reconnect tracking

Use the active-turn response-part snapshot boundary instead of notification-specific counters when deciding which notifications still need live emission.

(Written by Copilot)

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

* chat: render persistent system notification response parts

Add a native chat response content kind for system notifications so in-turn notifications use the same compact checked-row treatment as system-initiated requests without disappearing when later content arrives.

(Written by Copilot)

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

* agentHost: restore Copilot system notifications

Use persisted assistant turn boundaries to restore in-turn notifications as response parts and idle wake-up notifications as system-initiated turns.

(Written by Copilot)

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Rob Lourens
2026-07-03 19:24:02 -07:00
committed by GitHub
parent 710b8c0cb4
commit efa4ddddc3
17 changed files with 345 additions and 50 deletions
@@ -2473,7 +2473,7 @@ export class CopilotAgentSession extends Disposable {
turnId: this._turnId,
part: {
kind: ResponsePartKind.SystemNotification,
content: notification.content,
content: notification.messageText,
},
});
return;
@@ -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,
};
@@ -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;
}
@@ -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/' },
]);
});
@@ -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 };
/**
@@ -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: '<system_notification>\nShell command completed\n</system_notification>',
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: '<system_notification>\nAgent completed\n</system_notification>',
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: '<system_notification>\nInstruction discovered\n</system_notification>',
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' } },
@@ -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<string, ChatToolInvocation>;
readonly seedEmittedLengths?: ReadonlyMap<string, number>;
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<ToolCallResponsePart>, 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();
@@ -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);
}
@@ -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;
}
}
@@ -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<Ch
templateData.renderedParts = [];
const label = element.systemInitiatedLabel ?? element.messageText;
const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(label));
templateData.elementDisposables.add(rendered);
rendered.element.classList.add('progress-step');
const progressPart = this.instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined);
templateData.elementDisposables.add(progressPart);
templateData.value.appendChild(progressPart.domNode);
const notificationPart = this.instantiationService.createInstance(
ChatSystemNotificationContentPart,
{ kind: 'systemNotification', content: new MarkdownString(label) },
this.chatContentMarkdownRenderer,
);
templateData.elementDisposables.add(notificationPart);
templateData.value.appendChild(notificationPart.domNode);
}
/**
@@ -2437,6 +2438,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
return this.renderMultiDiffData(content, templateData, context);
} else if (content.kind === 'progressMessage') {
return this.instantiationService.createInstance(ChatProgressContentPart, content, this.chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, content.shimmer);
} else if (content.kind === 'systemNotification') {
return this.instantiationService.createInstance(ChatSystemNotificationContentPart, content, this.chatContentMarkdownRenderer);
} else if (content.kind === 'working') {
return this.instantiationService.createInstance(ChatWorkingProgressContentPart, content, this.chatContentMarkdownRenderer, context);
} else if (content.kind === 'progressTask' || content.kind === 'progressTaskSerialized') {
@@ -259,6 +259,11 @@ export interface IChatProgressMessage {
shimmer?: boolean;
}
export interface IChatSystemNotificationPart {
content: IMarkdownString;
kind: 'systemNotification';
}
export interface IChatTask extends IChatTaskDto {
deferred: DeferredPromise<string | void>;
progress: (IChatWarningMessage | IChatContentReference)[];
@@ -1341,6 +1346,7 @@ export type IChatProgress =
| IChatContentInlineReference
| IChatCodeCitation
| IChatProgressMessage
| IChatSystemNotificationPart
| IChatTask
| IChatTaskResult
| IChatCommandButton
@@ -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
@@ -76,6 +76,7 @@ const responsePartSchema = Adapt.v<Exclude<IChatProgressResponseContent, IChatMc
case 'markdownVuln':
case 'notebookEditGroup':
case 'progressMessage':
case 'systemNotification':
case 'pullRequest':
case 'questionCarousel':
case 'planReview':
@@ -2680,6 +2680,23 @@ suite('AgentHostChatContribution', () => {
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);
@@ -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', () => {
@@ -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,
});
});
});
@@ -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' });