feat: handling platform event mcp notification for acp (#10038)

This commit is contained in:
Lifei Zhou 2026-06-27 14:32:26 +10:00 committed by GitHub
parent 98a13fff4b
commit e2a2a64d3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 169 additions and 17 deletions

View file

@ -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());
}
}

View file

@ -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();
});
});

View file

@ -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<string, unknown>;
export function toolNotificationChange(
update: ToolCallUpdate
): Extract<AcpChatStateChange, { type: 'notification' }> | 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';
}
}

View file

@ -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<void> {
@ -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<void> {