From 035456a0ece3ecffccd5b04dbfe207f53eab6cad Mon Sep 17 00:00:00 2001 From: Nich Date: Mon, 1 Jun 2026 14:34:27 +0100 Subject: [PATCH 1/9] Add generic message embeds and actions --- .../mods/workspace/messaging/mod.py | 8 +- .../src/pages/messaging/MessagingView.tsx | 54 +++++++++ .../messaging/components/MessageRenderer.tsx | 108 +++++++++++++++++- sdk/studio/src/stores/chatStore.ts | 40 +++++++ sdk/studio/src/types/events.ts | 5 +- sdk/studio/src/types/message.ts | 51 ++++++++- tests/mods/test_messaging_content_embeds.py | 42 +++++++ 7 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 tests/mods/test_messaging_content_embeds.py diff --git a/sdk/src/openagents/mods/workspace/messaging/mod.py b/sdk/src/openagents/mods/workspace/messaging/mod.py index b086cff82..e40789ef5 100644 --- a/sdk/src/openagents/mods/workspace/messaging/mod.py +++ b/sdk/src/openagents/mods/workspace/messaging/mod.py @@ -2274,12 +2274,8 @@ def _extract_content_from_event(self, event: Event) -> Dict[str, Any]: # Handle nested content structure (payload.content) if "content" in event.payload and isinstance(event.payload["content"], dict): content = event.payload["content"] - result = {"text": content.get("text", "")} - - # Include files if present - if "files" in content and content["files"]: - result["files"] = content["files"] - + result = dict(content) + result["text"] = result.get("text", "") return result else: return {"text": ""} diff --git a/sdk/studio/src/pages/messaging/MessagingView.tsx b/sdk/studio/src/pages/messaging/MessagingView.tsx index 3a8245a77..982aa19d2 100644 --- a/sdk/studio/src/pages/messaging/MessagingView.tsx +++ b/sdk/studio/src/pages/messaging/MessagingView.tsx @@ -22,6 +22,8 @@ import { extractProjectIdFromChannel, } from "@/utils/projectUtils" import ProjectChatRoom from "./components/ProjectChatRoom" +import { EventNames } from "@/types/events" +import { MessageAction, UnifiedMessage } from "@/types/message" const ThreadMessagingViewEventBased: React.FC = () => { const { t } = useTranslation("messaging") @@ -544,6 +546,57 @@ const ThreadMessagingViewEventBased: React.FC = () => { ] ) + const handleMessageAction = useCallback( + async ( + message: UnifiedMessage, + action: MessageAction, + values: Record + ) => { + if (!currentChannel || !connector) { + toast.error("Action responses are only available in channels.") + return + } + + const actionResponse = { + source_message_id: message.id, + source_message_sender: message.senderId, + action_id: action.id, + action_label: action.label, + value: action.value || {}, + inputs: values, + } + const detailLines = Object.entries({ + ...(action.value || {}), + ...values, + }).map(([key, value]) => `${key}: ${String(value)}`) + + const response = await connector.sendEvent({ + event_name: EventNames.THREAD_CHANNEL_MESSAGE_POST, + source_id: connectionStatus.agentId || agentName, + destination_id: `channel:${currentChannel}`, + payload: { + channel: currentChannel, + content: { + text: [ + `Action response: ${action.label}`, + `source_message_id: ${message.id}`, + `action_id: ${action.id}`, + ...detailLines, + ].join("\n"), + schema: "openagents.message.v1", + action_response: actionResponse, + }, + message_type: "channel_message", + }, + }) + + if (!response?.success) { + toast.error(response?.message || "Failed to submit action response.") + } + }, + [agentName, connectionStatus.agentId, connector, currentChannel] + ) + // Handle reply and quote actions const startReply = useCallback( (messageId: string, text: string, author: string) => { @@ -894,6 +947,7 @@ const ThreadMessagingViewEventBased: React.FC = () => { isDMChat={!!currentDirectMessage} disableReactions={isProjectChannelActive} disableQuotes={isProjectChannelActive} + onMessageAction={handleMessageAction} networkHost={connector?.getHost()} networkPort={connector?.getPort()} agentSecret={connector?.getSecret()} diff --git a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx index 2e41ebc67..3021a7a81 100644 --- a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx +++ b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx @@ -10,7 +10,7 @@ */ import React, { useState, useRef } from "react" -import { UnifiedMessage } from "@/types/message" +import { MessageAction, MessageEmbed, UnifiedMessage } from "@/types/message" import { ThreadMessage } from "@/types/events" import { formatRelativeTimestamp, @@ -55,6 +55,11 @@ interface MessageRendererProps { disableReactions?: boolean // Whether to disable quote features (for project channel) disableQuotes?: boolean + onMessageAction?: ( + message: UnifiedMessage, + action: MessageAction, + values: Record + ) => void // Network connection details for attachment downloads networkHost?: string networkPort?: number @@ -72,6 +77,7 @@ const MessageRenderer: React.FC = ({ isDMChat = false, disableReactions = false, disableQuotes = false, + onMessageAction, networkHost, networkPort, agentSecret, @@ -106,6 +112,8 @@ const MessageRenderer: React.FC = ({ senderId: threadMsg.sender_id, timestamp: threadMsg.timestamp, content: threadMsg.content?.text || "", + embeds: threadMsg.content?.embeds || [], + actions: threadMsg.content?.actions || [], replyToId: threadMsg.reply_to_id, reactions: threadMsg.reactions, attachments, @@ -118,6 +126,8 @@ const MessageRenderer: React.FC = ({ senderId: unifiedMsg.senderId, timestamp: unifiedMsg.timestamp, content: unifiedMsg.content, + embeds: unifiedMsg.embeds || [], + actions: unifiedMsg.actions || [], replyToId: unifiedMsg.replyToId, reactions: unifiedMsg.reactions, attachments: unifiedMsg.attachments, @@ -203,6 +213,99 @@ const MessageRenderer: React.FC = ({ setCollapsedThreads(newCollapsed) } + const collectActionValues = (action: MessageAction): Record | null => { + const values: Record = {} + for (const requirement of action.requires || []) { + if (requirement.type === "boolean") { + values[requirement.name] = true + continue + } + const label = requirement.label || requirement.name + const value = window.prompt(label) + if (requirement.required && (!value || !value.trim())) { + return null + } + values[requirement.name] = value || "" + } + return values + } + + const handleMessageAction = (message: UnifiedMessage, action: MessageAction) => { + if (action.type === "link" && action.href) { + window.open(action.href, "_blank", "noopener,noreferrer") + return + } + const values = collectActionValues(action) + if (values === null) { + return + } + onMessageAction?.(message, action, values) + } + + const renderMessageEmbeds = (embeds?: MessageEmbed[]) => { + if (!embeds || embeds.length === 0) return null + return ( +
+ {embeds.map((embed, index) => ( +
+
+
+ {embed.title || embed.type} +
+
+ {embed.type} +
+
+ {embed.body && ( +
+ +
+ )} + {embed.fields && embed.fields.length > 0 && ( +
+ {embed.fields.map((field, fieldIndex) => ( +
+
+ {field.label} +
+
+ {field.value} +
+
+ ))} +
+ )} +
+ ))} +
+ ) + } + + const renderMessageActions = (message: UnifiedMessage, actions?: MessageAction[]) => { + if (!actions || actions.length === 0 || !onMessageAction) return null + return ( +
+ {actions.map((action) => { + const isDanger = action.style === "danger" + const isPrimary = action.style === "primary" + return ( + + ) + })} +
+ ) + } + // For compatibility with old ThreadMessage format, need to build thread structure const buildThreadStructureForThreadMessages = ( threadMessages: ThreadMessage[] @@ -312,6 +415,7 @@ const MessageRenderer: React.FC = ({ agentSecret={agentSecret} /> )} + {renderMessageEmbeds(messageProps.embeds)} {/* Reaction display */} @@ -510,6 +614,8 @@ const MessageRenderer: React.FC = ({ agentSecret={agentSecret} /> )} + {renderMessageEmbeds(message.embeds)} + {renderMessageActions(message, message.actions)} {/* Reaction display */} diff --git a/sdk/studio/src/stores/chatStore.ts b/sdk/studio/src/stores/chatStore.ts index c8891ce1d..2e5b81b88 100644 --- a/sdk/studio/src/stores/chatStore.ts +++ b/sdk/studio/src/stores/chatStore.ts @@ -2264,6 +2264,8 @@ export const useChatStore = create((set, get) => ({ } if (messageData.channel && messageData.content) { + const contentObject = + typeof messageData.content === "object" ? messageData.content : undefined; // Construct unified message format const unifiedMessage: UnifiedMessage = { id: @@ -2277,6 +2279,14 @@ export const useChatStore = create((set, get) => ({ : messageData.content.text || "", type: messageData.message_type, channel: messageData.channel, + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) + ? contentObject.embeds + : undefined, + actions: Array.isArray(contentObject?.actions) + ? contentObject.actions + : undefined, + rawContent: contentObject, replyToId: messageData.reply_to_id || event.reply_to_id, threadLevel: messageData.thread_level || 1, reactions: messageData.reactions, @@ -2367,6 +2377,8 @@ export const useChatStore = create((set, get) => ({ const projectId = messageData.project_id; const senderId = messageData.sender_id; const content = messageData.content; + const contentObject = + typeof content === "object" ? content : undefined; if (projectId && content) { // Find the project channel @@ -2392,6 +2404,14 @@ export const useChatStore = create((set, get) => ({ content: messageText, type: "channel_message", channel: projectChannel, + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) + ? contentObject.embeds + : undefined, + actions: Array.isArray(contentObject?.actions) + ? contentObject.actions + : undefined, + rawContent: contentObject, replyToId: messageData.reply_to_id, }; @@ -2425,6 +2445,8 @@ export const useChatStore = create((set, get) => ({ }); if (messageData.channel && messageData.content) { + const contentObject = + typeof messageData.content === "object" ? messageData.content : undefined; // Construct unified message format // Fix: use correct sender ID - event.source_id or event.sender_id is the actual reply author // messageData.original_sender refers to the original author of the replied message, not the reply sender @@ -2440,6 +2462,14 @@ export const useChatStore = create((set, get) => ({ : messageData.content.text || "", type: messageData.message_type, channel: messageData.channel, + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) + ? contentObject.embeds + : undefined, + actions: Array.isArray(contentObject?.actions) + ? contentObject.actions + : undefined, + rawContent: contentObject, replyToId: messageData.reply_to_id, threadLevel: messageData.thread_level || 1, reactions: messageData.reactions, @@ -2581,6 +2611,8 @@ export const useChatStore = create((set, get) => ({ // Construct unified message format if content exists if (content || messageData.content) { + const contentObject = + typeof messageData.content === "object" ? messageData.content : undefined; // Format timestamp: convert Unix timestamp to ISO string if needed let timestampStr = ""; if (event.timestamp) { @@ -2605,6 +2637,14 @@ export const useChatStore = create((set, get) => ({ timestamp: timestampStr, content: content, type: messageData.message_type || "direct_message", + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) + ? contentObject.embeds + : undefined, + actions: Array.isArray(contentObject?.actions) + ? contentObject.actions + : undefined, + rawContent: contentObject, targetUserId: messageData.target_agent_id, reactions: messageData.reactions, }; diff --git a/sdk/studio/src/types/events.ts b/sdk/studio/src/types/events.ts index c34864a7b..c486da11c 100644 --- a/sdk/studio/src/types/events.ts +++ b/sdk/studio/src/types/events.ts @@ -76,6 +76,9 @@ export interface ThreadMessage { timestamp: string; content: { text: string; + schema?: string; + embeds?: Array>; + actions?: Array>; files?: Array<{ file_id: string; filename: string; @@ -128,4 +131,4 @@ export interface NetworkInfo { mode: 'centralized' | 'decentralized'; mods: string[]; agent_count: number; -} \ No newline at end of file +} diff --git a/sdk/studio/src/types/message.ts b/sdk/studio/src/types/message.ts index 20fc7843c..22692ee18 100644 --- a/sdk/studio/src/types/message.ts +++ b/sdk/studio/src/types/message.ts @@ -4,12 +4,51 @@ */ // Unified message type - for internal frontend use +export interface MessageEmbed { + id?: string; + type: string; + title?: string; + body?: string; + fields?: Array<{ + label: string; + value: string; + }>; + data?: Record; + [key: string]: any; +} + +export interface MessageActionInput { + name: string; + type: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | string; + label?: string; + required?: boolean; + options?: Array<{ + label: string; + value: string; + }>; +} + +export interface MessageAction { + id: string; + type: 'submit' | 'link' | string; + label: string; + style?: 'primary' | 'secondary' | 'danger' | string; + href?: string; + value?: Record; + requires?: MessageActionInput[]; + [key: string]: any; +} + export interface UnifiedMessage { // Basic information id: string; senderId: string; timestamp: string; content: string; + schema?: string; + embeds?: MessageEmbed[]; + actions?: MessageAction[]; + rawContent?: Record; // Message type type: 'direct_message' | 'channel_message' | 'reply_message'; @@ -59,6 +98,9 @@ export interface RawThreadMessage { timestamp: string; content: { text: string; + schema?: string; + embeds?: MessageEmbed[]; + actions?: MessageAction[]; files?: Array<{ file_id: string; filename: string; @@ -127,11 +169,18 @@ export class MessageAdapter { } } + const contentObject = + raw.content && typeof raw.content === 'object' ? raw.content as Record : undefined; + return { id: (isDirectMessage ? raw.event_id : raw.message_id) || '', senderId: (isDirectMessage ? raw.source_id : raw.sender_id) || '', timestamp: raw.timestamp, // 10 digits vs 13 digits, content: isDirectMessage ? raw.payload.content.text : content, + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) ? contentObject.embeds : undefined, + actions: Array.isArray(contentObject?.actions) ? contentObject.actions : undefined, + rawContent: contentObject, type: isDirectMessage ? raw.payload.message_type : raw.message_type, channel: isDirectMessage ? '' : raw.channel, targetUserId: isDirectMessage ? raw.payload.target_agent_id : raw.target_agent_id, @@ -347,4 +396,4 @@ export class MessageUtils { return { structure, rootMessageIds: rootMessages }; } -} \ No newline at end of file +} diff --git a/tests/mods/test_messaging_content_embeds.py b/tests/mods/test_messaging_content_embeds.py new file mode 100644 index 000000000..0b8568c4a --- /dev/null +++ b/tests/mods/test_messaging_content_embeds.py @@ -0,0 +1,42 @@ +from openagents.models.event import Event +from openagents.mods.workspace.messaging.mod import ThreadMessagingNetworkMod + + +def test_extract_content_preserves_arbitrary_message_embeds_and_actions(): + mod = ThreadMessagingNetworkMod() + event = Event( + event_name="thread.channel_message.post", + source_id="agent-a", + destination_id="channel:approvals", + payload={ + "channel": "approvals", + "message_type": "channel_message", + "content": { + "text": "Approval requested", + "schema": "openagents.message.v1", + "embeds": [ + { + "id": "approval-1", + "type": "approval_request", + "title": "Human approval requested", + } + ], + "actions": [ + { + "id": "approve", + "type": "submit", + "label": "Yes, approved", + } + ], + "custom": {"vendor": "example"}, + }, + }, + ) + + content = mod._extract_content_from_event(event) + + assert content["text"] == "Approval requested" + assert content["schema"] == "openagents.message.v1" + assert content["embeds"][0]["type"] == "approval_request" + assert content["actions"][0]["id"] == "approve" + assert content["custom"] == {"vendor": "example"} From ac8f4b6909d35a6471d4a737218a4f05ef42cabd Mon Sep 17 00:00:00 2001 From: Nich Date: Mon, 1 Jun 2026 15:20:50 +0100 Subject: [PATCH 2/9] Address structured message review feedback --- .../mods/workspace/messaging/mod.py | 8 ++- .../src/pages/messaging/MessagingView.tsx | 46 ++++++++------ .../messaging/components/MessageRenderer.tsx | 13 ++-- sdk/studio/src/stores/chatStore.ts | 63 +++++++------------ sdk/studio/src/types/events.ts | 6 +- sdk/studio/src/types/message.ts | 44 ++++++------- 6 files changed, 90 insertions(+), 90 deletions(-) diff --git a/sdk/src/openagents/mods/workspace/messaging/mod.py b/sdk/src/openagents/mods/workspace/messaging/mod.py index e40789ef5..147d5fbe5 100644 --- a/sdk/src/openagents/mods/workspace/messaging/mod.py +++ b/sdk/src/openagents/mods/workspace/messaging/mod.py @@ -2258,15 +2258,17 @@ def _extract_text_from_event(self, event: Event) -> str: return "" def _extract_content_from_event(self, event: Event) -> Dict[str, Any]: - """Extract full content (text and files) from an Event object's payload. + """Extract message content from an Event object's payload. - This handles the nested content structure: payload.content + This preserves all fields in the nested payload.content mapping, such + as text, files, schema, embeds, actions, and custom extension fields, + while ensuring the returned dict always has a text key. Args: event: The Event object to extract content from Returns: - Dict with text and optionally files + Dict containing all payload.content fields plus a guaranteed text field """ if not event or not event.payload: return {"text": ""} diff --git a/sdk/studio/src/pages/messaging/MessagingView.tsx b/sdk/studio/src/pages/messaging/MessagingView.tsx index 982aa19d2..aeb3fa1d6 100644 --- a/sdk/studio/src/pages/messaging/MessagingView.tsx +++ b/sdk/studio/src/pages/messaging/MessagingView.tsx @@ -570,28 +570,34 @@ const ThreadMessagingViewEventBased: React.FC = () => { ...values, }).map(([key, value]) => `${key}: ${String(value)}`) - const response = await connector.sendEvent({ - event_name: EventNames.THREAD_CHANNEL_MESSAGE_POST, - source_id: connectionStatus.agentId || agentName, - destination_id: `channel:${currentChannel}`, - payload: { - channel: currentChannel, - content: { - text: [ - `Action response: ${action.label}`, - `source_message_id: ${message.id}`, - `action_id: ${action.id}`, - ...detailLines, - ].join("\n"), - schema: "openagents.message.v1", - action_response: actionResponse, + try { + const response = await connector.sendEvent({ + event_name: EventNames.THREAD_CHANNEL_MESSAGE_POST, + source_id: connectionStatus.agentId || agentName, + destination_id: `channel:${currentChannel}`, + payload: { + channel: currentChannel, + content: { + text: [ + `Action response: ${action.label}`, + `source_message_id: ${message.id}`, + `action_id: ${action.id}`, + ...detailLines, + ].join("\n"), + schema: "openagents.message.v1", + action_response: actionResponse, + }, + message_type: "channel_message", }, - message_type: "channel_message", - }, - }) + }) - if (!response?.success) { - toast.error(response?.message || "Failed to submit action response.") + if (!response?.success) { + toast.error(response?.message || "Failed to submit action response.") + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to submit action response." + toast.error(message) } }, [agentName, connectionStatus.agentId, connector, currentChannel] diff --git a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx index 3021a7a81..c90529065 100644 --- a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx +++ b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx @@ -217,7 +217,7 @@ const MessageRenderer: React.FC = ({ const values: Record = {} for (const requirement of action.requires || []) { if (requirement.type === "boolean") { - values[requirement.name] = true + values[requirement.name] = window.confirm(requirement.label || requirement.name) continue } const label = requirement.label || requirement.name @@ -239,7 +239,9 @@ const MessageRenderer: React.FC = ({ if (values === null) { return } - onMessageAction?.(message, action, values) + if (onMessageAction) { + onMessageAction(message, action, values) + } } const renderMessageEmbeds = (embeds?: MessageEmbed[]) => { @@ -285,10 +287,13 @@ const MessageRenderer: React.FC = ({ } const renderMessageActions = (message: UnifiedMessage, actions?: MessageAction[]) => { - if (!actions || actions.length === 0 || !onMessageAction) return null + const renderableActions = actions?.filter( + (action) => (action.type === "link" && action.href) || onMessageAction + ) + if (!renderableActions || renderableActions.length === 0) return null return (
- {actions.map((action) => { + {renderableActions.map((action) => { const isDanger = action.style === "danger" const isPrimary = action.style === "primary" return ( diff --git a/sdk/studio/src/stores/chatStore.ts b/sdk/studio/src/stores/chatStore.ts index 2e5b81b88..d9634f036 100644 --- a/sdk/studio/src/stores/chatStore.ts +++ b/sdk/studio/src/stores/chatStore.ts @@ -8,6 +8,25 @@ import { import { eventRouter } from "@/services/eventRouter"; import { notificationService } from "@/services/notificationService"; +const asContentObject = (value: any): Record | undefined => + value && typeof value === "object" && !Array.isArray(value) + ? value + : undefined; + +const structuredMessageFields = (content: any) => { + const contentObject = asContentObject(content); + return { + schema: contentObject?.schema, + embeds: Array.isArray(contentObject?.embeds) + ? contentObject.embeds + : undefined, + actions: Array.isArray(contentObject?.actions) + ? contentObject.actions + : undefined, + rawContent: contentObject, + }; +}; + // Message sending status export type MessageStatus = "sending" | "sent" | "failed"; @@ -2264,8 +2283,6 @@ export const useChatStore = create((set, get) => ({ } if (messageData.channel && messageData.content) { - const contentObject = - typeof messageData.content === "object" ? messageData.content : undefined; // Construct unified message format const unifiedMessage: UnifiedMessage = { id: @@ -2279,14 +2296,7 @@ export const useChatStore = create((set, get) => ({ : messageData.content.text || "", type: messageData.message_type, channel: messageData.channel, - schema: contentObject?.schema, - embeds: Array.isArray(contentObject?.embeds) - ? contentObject.embeds - : undefined, - actions: Array.isArray(contentObject?.actions) - ? contentObject.actions - : undefined, - rawContent: contentObject, + ...structuredMessageFields(messageData.content), replyToId: messageData.reply_to_id || event.reply_to_id, threadLevel: messageData.thread_level || 1, reactions: messageData.reactions, @@ -2377,8 +2387,6 @@ export const useChatStore = create((set, get) => ({ const projectId = messageData.project_id; const senderId = messageData.sender_id; const content = messageData.content; - const contentObject = - typeof content === "object" ? content : undefined; if (projectId && content) { // Find the project channel @@ -2404,14 +2412,7 @@ export const useChatStore = create((set, get) => ({ content: messageText, type: "channel_message", channel: projectChannel, - schema: contentObject?.schema, - embeds: Array.isArray(contentObject?.embeds) - ? contentObject.embeds - : undefined, - actions: Array.isArray(contentObject?.actions) - ? contentObject.actions - : undefined, - rawContent: contentObject, + ...structuredMessageFields(content), replyToId: messageData.reply_to_id, }; @@ -2445,8 +2446,6 @@ export const useChatStore = create((set, get) => ({ }); if (messageData.channel && messageData.content) { - const contentObject = - typeof messageData.content === "object" ? messageData.content : undefined; // Construct unified message format // Fix: use correct sender ID - event.source_id or event.sender_id is the actual reply author // messageData.original_sender refers to the original author of the replied message, not the reply sender @@ -2462,14 +2461,7 @@ export const useChatStore = create((set, get) => ({ : messageData.content.text || "", type: messageData.message_type, channel: messageData.channel, - schema: contentObject?.schema, - embeds: Array.isArray(contentObject?.embeds) - ? contentObject.embeds - : undefined, - actions: Array.isArray(contentObject?.actions) - ? contentObject.actions - : undefined, - rawContent: contentObject, + ...structuredMessageFields(messageData.content), replyToId: messageData.reply_to_id, threadLevel: messageData.thread_level || 1, reactions: messageData.reactions, @@ -2611,8 +2603,6 @@ export const useChatStore = create((set, get) => ({ // Construct unified message format if content exists if (content || messageData.content) { - const contentObject = - typeof messageData.content === "object" ? messageData.content : undefined; // Format timestamp: convert Unix timestamp to ISO string if needed let timestampStr = ""; if (event.timestamp) { @@ -2637,14 +2627,7 @@ export const useChatStore = create((set, get) => ({ timestamp: timestampStr, content: content, type: messageData.message_type || "direct_message", - schema: contentObject?.schema, - embeds: Array.isArray(contentObject?.embeds) - ? contentObject.embeds - : undefined, - actions: Array.isArray(contentObject?.actions) - ? contentObject.actions - : undefined, - rawContent: contentObject, + ...structuredMessageFields(messageData.content), targetUserId: messageData.target_agent_id, reactions: messageData.reactions, }; diff --git a/sdk/studio/src/types/events.ts b/sdk/studio/src/types/events.ts index c486da11c..1dd04a8f7 100644 --- a/sdk/studio/src/types/events.ts +++ b/sdk/studio/src/types/events.ts @@ -2,6 +2,8 @@ * TypeScript type definitions for the new OpenAgents event system */ +import type { MessageAction, MessageEmbed } from './message'; + export interface EventResponse { success: boolean; message?: string; @@ -77,8 +79,8 @@ export interface ThreadMessage { content: { text: string; schema?: string; - embeds?: Array>; - actions?: Array>; + embeds?: MessageEmbed[]; + actions?: MessageAction[]; files?: Array<{ file_id: string; filename: string; diff --git a/sdk/studio/src/types/message.ts b/sdk/studio/src/types/message.ts index 22692ee18..a499eae6c 100644 --- a/sdk/studio/src/types/message.ts +++ b/sdk/studio/src/types/message.ts @@ -4,6 +4,8 @@ */ // Unified message type - for internal frontend use +type ExtensibleString = T | (string & {}); + export interface MessageEmbed { id?: string; type: string; @@ -19,7 +21,7 @@ export interface MessageEmbed { export interface MessageActionInput { name: string; - type: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | string; + type: ExtensibleString<'text' | 'textarea' | 'number' | 'boolean' | 'select'>; label?: string; required?: boolean; options?: Array<{ @@ -30,9 +32,9 @@ export interface MessageActionInput { export interface MessageAction { id: string; - type: 'submit' | 'link' | string; + type: ExtensibleString<'submit' | 'link'>; label: string; - style?: 'primary' | 'secondary' | 'danger' | string; + style?: ExtensibleString<'primary' | 'secondary' | 'danger'>; href?: string; value?: Record; requires?: MessageActionInput[]; @@ -135,10 +137,15 @@ export class MessageAdapter { * Convert RawThreadMessage to UnifiedMessage */ static fromRawThreadMessage(raw: RawThreadMessage): UnifiedMessage { + const isDirectMessage = raw?.payload?.message_type === 'direct_message'; + const rawContent = isDirectMessage ? raw.payload?.content : raw.content; + const contentObject = + rawContent && typeof rawContent === 'object' ? rawContent as Record : undefined; + // Extract files from content.files const attachments: UnifiedMessage['attachments'] = - raw.content && typeof raw.content === 'object' && raw.content.files - ? raw.content.files.map((f: any) => ({ + contentObject?.files + ? contentObject.files.map((f: any) => ({ fileId: f.file_id, filename: f.filename, size: f.size, @@ -147,36 +154,31 @@ export class MessageAdapter { })) : undefined; - const isDirectMessage = raw?.payload?.message_type === 'direct_message'; - // Handle different content formats let content = ''; - if (raw.content) { - if (typeof raw.content === 'string') { + if (rawContent) { + if (typeof rawContent === 'string') { // content is a string - content = raw.content; - } else if (typeof raw.content === 'object' && raw.content.text !== undefined) { + content = rawContent; + } else if (typeof rawContent === 'object' && rawContent.text !== undefined) { // content is an object with text field (even if text is empty string it's valid) - content = raw.content.text; - } else if (typeof raw.content === 'object') { + content = rawContent.text; + } else if (typeof rawContent === 'object') { // content is an object but without text field, try other fields or convert - console.warn('MessageAdapter: Content object missing text field:', raw.content); - content = raw.content.message || raw.content.value || String(raw.content); + console.warn('MessageAdapter: Content object missing text field:', rawContent); + content = rawContent.message || rawContent.value || String(rawContent); } else { // Other cases, try to convert to string - console.warn('MessageAdapter: Unexpected content format:', raw.content); - content = String(raw.content); + console.warn('MessageAdapter: Unexpected content format:', rawContent); + content = String(rawContent); } } - const contentObject = - raw.content && typeof raw.content === 'object' ? raw.content as Record : undefined; - return { id: (isDirectMessage ? raw.event_id : raw.message_id) || '', senderId: (isDirectMessage ? raw.source_id : raw.sender_id) || '', timestamp: raw.timestamp, // 10 digits vs 13 digits, - content: isDirectMessage ? raw.payload.content.text : content, + content, schema: contentObject?.schema, embeds: Array.isArray(contentObject?.embeds) ? contentObject.embeds : undefined, actions: Array.isArray(contentObject?.actions) ? contentObject.actions : undefined, From 10effb3588b132965ab10f8bfe3b1b2e151da4ef Mon Sep 17 00:00:00 2001 From: Nich Date: Mon, 1 Jun 2026 15:46:12 +0100 Subject: [PATCH 3/9] Replace action prompts with input dialog --- .../messaging/components/MessageRenderer.tsx | 204 ++++++++++++++++-- 1 file changed, 184 insertions(+), 20 deletions(-) diff --git a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx index c90529065..dc2e6d72c 100644 --- a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx +++ b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx @@ -89,6 +89,14 @@ const MessageRenderer: React.FC = ({ const [collapsedThreads, setCollapsedThreads] = useState>( new Set() ) + const [pendingAction, setPendingAction] = useState<{ + message: UnifiedMessage + action: MessageAction + } | null>(null) + const [actionInputValues, setActionInputValues] = useState>( + {} + ) + const [actionInputError, setActionInputError] = useState(null) const messagesEndRef = useRef(null) // Remove auto-scroll logic from MessageRenderer - MessagingView handles this @@ -213,37 +221,189 @@ const MessageRenderer: React.FC = ({ setCollapsedThreads(newCollapsed) } - const collectActionValues = (action: MessageAction): Record | null => { - const values: Record = {} - for (const requirement of action.requires || []) { - if (requirement.type === "boolean") { - values[requirement.name] = window.confirm(requirement.label || requirement.name) - continue - } - const label = requirement.label || requirement.name - const value = window.prompt(label) - if (requirement.required && (!value || !value.trim())) { - return null - } - values[requirement.name] = value || "" - } - return values - } - const handleMessageAction = (message: UnifiedMessage, action: MessageAction) => { if (action.type === "link" && action.href) { window.open(action.href, "_blank", "noopener,noreferrer") return } - const values = collectActionValues(action) - if (values === null) { + if (action.requires && action.requires.length > 0) { + const initialValues = action.requires.reduce>( + (values, requirement) => { + values[requirement.name] = requirement.type === "boolean" ? false : "" + return values + }, + {} + ) + setActionInputValues(initialValues) + setActionInputError(null) + setPendingAction({ message, action }) return } if (onMessageAction) { - onMessageAction(message, action, values) + onMessageAction(message, action, {}) } } + const updateActionInputValue = (name: string, value: any) => { + setActionInputValues((current) => ({ + ...current, + [name]: value, + })) + setActionInputError(null) + } + + const submitPendingAction = () => { + if (!pendingAction || !onMessageAction) return + + for (const requirement of pendingAction.action.requires || []) { + const value = actionInputValues[requirement.name] + const isEmpty = + value === undefined || + value === null || + (typeof value === "string" && value.trim() === "") + if (requirement.required && isEmpty) { + setActionInputError(`${requirement.label || requirement.name} is required.`) + return + } + } + + onMessageAction( + pendingAction.message, + pendingAction.action, + actionInputValues + ) + setPendingAction(null) + setActionInputValues({}) + setActionInputError(null) + } + + const renderActionRequirementInput = ( + requirement: NonNullable[number] + ) => { + const value = actionInputValues[requirement.name] + const label = requirement.label || requirement.name + const id = `message-action-${pendingAction?.action.id}-${requirement.name}` + + if (requirement.type === "textarea") { + return ( +