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' });