From e2a2a64d3caeb84e3766e4de598ccd2f825ec239 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 27 Jun 2026 14:32:26 +1000 Subject: [PATCH] feat: handling platform event mcp notification for acp (#10038) --- .../src/acp/server/tool_notifications.rs | 57 ++++++++++++++++- .../acp/__tests__/chatNotifications.test.ts | 48 ++++++++++++++ .../src/acp/adapter/toolNotifications.ts | 62 ++++++++++++++----- ui/desktop/src/acp/chatNotifications.ts | 19 ++++++ 4 files changed, 169 insertions(+), 17 deletions(-) diff --git a/crates/goose/src/acp/server/tool_notifications.rs b/crates/goose/src/acp/server/tool_notifications.rs index 412dd16c34..95e7bbdb64 100644 --- a/crates/goose/src/acp/server/tool_notifications.rs +++ b/crates/goose/src/acp/server/tool_notifications.rs @@ -13,6 +13,9 @@ enum ToolNotification { Progress { params: ProgressNotificationParam, }, + PlatformEvent { + params: serde_json::Value, + }, } pub(super) fn tool_notification_update( @@ -26,6 +29,13 @@ pub(super) fn tool_notification_update( ServerNotification::ProgressNotification(notification) => ToolNotification::Progress { params: notification.params, }, + ServerNotification::CustomNotification(notification) + if notification.method == "platform_event" => + { + ToolNotification::PlatformEvent { + params: notification.params.unwrap_or(serde_json::Value::Null), + } + } _ => return None, }; @@ -48,8 +58,9 @@ pub(super) fn tool_notification_update( mod tests { use super::tool_notification_update; use rmcp::model::{ - CancelledNotificationParam, LoggingLevel, LoggingMessageNotificationParam, Notification, - NumberOrString, ProgressNotificationParam, ProgressToken, ServerNotification, + CancelledNotificationParam, CustomNotification, LoggingLevel, + LoggingMessageNotificationParam, Notification, NumberOrString, ProgressNotificationParam, + ProgressToken, ServerNotification, }; use serde_json::json; use std::sync::Arc; @@ -135,4 +146,46 @@ mod tests { assert!(tool_notification_update("tool_1", notification).is_none()); } + + #[test] + fn maps_platform_event_custom_notification_to_tool_update_meta() { + let notification = ServerNotification::CustomNotification(CustomNotification::new( + "platform_event", + Some(json!({ + "extension": "apps", + "event_type": "app_created", + "app_name": "platform-event-repro" + })), + )); + + let update = tool_notification_update("tool_1", notification).expect("expected update"); + let value = serde_json::to_value(update).expect("update should serialize"); + + assert_eq!(value["sessionUpdate"], "tool_call_update"); + assert_eq!(value["toolCallId"], "tool_1"); + assert_eq!(value["status"], "in_progress"); + assert_eq!(value["_meta"]["toolNotification"]["type"], "platform_event"); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["extension"], + "apps" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["event_type"], + "app_created" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["app_name"], + "platform-event-repro" + ); + } + + #[test] + fn ignores_non_platform_event_custom_notifications() { + let notification = ServerNotification::CustomNotification(CustomNotification::new( + "notifications/custom", + Some(json!({ "extension": "apps" })), + )); + + assert!(tool_notification_update("tool_1", notification).is_none()); + } } diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts index 98393a60d9..a5d6d68942 100644 --- a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Session } from '../../api'; import { AppEvents } from '../../constants/events'; import { ChatState } from '../../types/chatState'; +import { maybeHandlePlatformEvent } from '../../utils/platform_events'; import { handleAcpSessionNotification } from '../chatNotifications'; import type { AcpChatSessionSnapshot } from '../chatSessionStore'; import { acpChatSessionActions, acpChatSessionStore } from '../chatSessionStore'; @@ -21,6 +22,10 @@ vi.mock('../chatSessionStore', () => ({ }, })); +vi.mock('../../utils/platform_events', () => ({ + maybeHandlePlatformEvent: vi.fn(), +})); + const SESSION_ID = 'session-1'; function sessionInfoUpdate(title: string): SessionNotification { @@ -33,6 +38,27 @@ function sessionInfoUpdate(title: string): SessionNotification { }; } +function platformEventToolUpdate(status: 'in_progress' | 'completed'): SessionNotification { + return { + sessionId: SESSION_ID, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status, + _meta: { + toolNotification: { + type: 'platform_event', + params: { + extension: 'apps', + event_type: 'app_created', + app_name: 'platform-event-repro', + }, + }, + }, + }, + }; +} + function sessionWithName(name: string): Session { return { id: SESSION_ID, @@ -138,4 +164,26 @@ describe('handleAcpSessionNotification', () => { }) ); }); + + it('forwards live ACP platform events to the desktop platform event handler', async () => { + await handleAcpSessionNotification(platformEventToolUpdate('in_progress')); + + expect(maybeHandlePlatformEvent).toHaveBeenCalledWith( + { + method: 'platform_event', + params: { + extension: 'apps', + event_type: 'app_created', + app_name: 'platform-event-repro', + }, + }, + SESSION_ID + ); + }); + + it('does not forward completed platform event metadata as a live desktop event', async () => { + await handleAcpSessionNotification(platformEventToolUpdate('completed')); + + expect(maybeHandlePlatformEvent).not.toHaveBeenCalled(); + }); }); diff --git a/ui/desktop/src/acp/adapter/toolNotifications.ts b/ui/desktop/src/acp/adapter/toolNotifications.ts index 9bd5a70424..290136d3ce 100644 --- a/ui/desktop/src/acp/adapter/toolNotifications.ts +++ b/ui/desktop/src/acp/adapter/toolNotifications.ts @@ -4,14 +4,18 @@ import type { AcpChatStateChange } from './shared'; import { isRecord } from './shared'; type ToolNotification = - | { - type: 'message'; - params: LoggingMessageNotificationParams; - } - | { - type: 'progress'; - params: ProgressNotificationParams; - }; + | { + type: 'message'; + params: LoggingMessageNotificationParams; + } + | { + type: 'progress'; + params: ProgressNotificationParams; + } + | { + type: 'platform_event'; + params: PlatformEventParams; + }; type LoggingMessageNotificationParams = { level: string; @@ -26,20 +30,31 @@ type ProgressNotificationParams = { message?: string; }; +type PlatformEventParams = Record; + export function toolNotificationChange( update: ToolCallUpdate ): Extract | undefined { - const toolNotification = parseToolNotification(update._meta); - if (!toolNotification) { + const notification = toolNotificationEvent(update); + if (!notification) { return undefined; } return { type: 'notification', - notification: toNotificationEvent(update.toolCallId, toolNotification), + notification, }; } +export function toolNotificationEvent(update: ToolCallUpdate): NotificationEvent | undefined { + const toolNotification = parseToolNotification(update._meta); + if (!toolNotification) { + return undefined; + } + + return toNotificationEvent(update.toolCallId, toolNotification); +} + function parseToolNotification(meta: unknown): ToolNotification | undefined { if (!isRecord(meta)) { return undefined; @@ -60,6 +75,11 @@ function parseToolNotification(meta: unknown): ToolNotification | undefined { return params ? { type: 'progress', params } : undefined; } + if (toolNotification.type === 'platform_event') { + const params = parsePlatformEventParams(toolNotification.params); + return params ? { type: 'platform_event', params } : undefined; + } + return undefined; } @@ -92,6 +112,10 @@ function parseProgressParams(value: unknown): ProgressNotificationParams | undef }; } +function parsePlatformEventParams(value: unknown): PlatformEventParams | undefined { + return isRecord(value) ? value : undefined; +} + function toNotificationEvent( toolCallId: string, toolNotification: ToolNotification @@ -100,11 +124,19 @@ function toNotificationEvent( type: 'Notification', request_id: toolCallId, message: { - method: - toolNotification.type === 'message' - ? 'notifications/message' - : 'notifications/progress', + method: notificationMethod(toolNotification), params: toolNotification.params, }, }; } + +function notificationMethod(toolNotification: ToolNotification): string { + switch (toolNotification.type) { + case 'message': + return 'notifications/message'; + case 'progress': + return 'notifications/progress'; + case 'platform_event': + return 'platform_event'; + } +} diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts index ca1c31b8e0..ba67ad2520 100644 --- a/ui/desktop/src/acp/chatNotifications.ts +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -2,6 +2,8 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { SessionNotification } from '@agentclientprotocol/sdk'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { AppEvents } from '../constants/events'; +import { maybeHandlePlatformEvent } from '../utils/platform_events'; +import { toolNotificationEvent } from './adapter/toolNotifications'; import { acpChatSessionActions, acpChatSessionStore } from './chatSessionStore'; export function handleAcpSessionNotification(notification: SessionNotification): Promise { @@ -14,6 +16,7 @@ export function handleAcpSessionNotification(notification: SessionNotification): ? notification.update.title : undefined; acpChatSessionActions.applyAcpSessionNotification(notification); + maybeHandleLivePlatformEvent(notification); if (updatedName && updatedName !== sessionNameBeforeNotification) { window.dispatchEvent( @@ -26,6 +29,22 @@ export function handleAcpSessionNotification(notification: SessionNotification): return Promise.resolve(); } +function maybeHandleLivePlatformEvent(notification: SessionNotification): void { + const update = notification.update; + if ( + update.sessionUpdate !== 'tool_call_update' || + update.status === 'completed' || + update.status === 'failed' + ) { + return; + } + + const event = toolNotificationEvent(update); + if (event?.message.method === 'platform_event') { + maybeHandlePlatformEvent(event.message, notification.sessionId); + } +} + export function handleAcpGooseSessionNotification( notification: GooseSessionNotification_unstable ): Promise {