feat(messages): add sender-side wake recipient override
Senders can now force the recipient agent to wake on receipt regardless of the recipient's `messageResponseMode`. Surfaced as a "Wake recipient immediately" checkbox in MessageComposer and as a `wake_recipient` boolean param on the `fn_send_message` agent tool. Carried as `metadata.wakeRecipient: true` on the message; the heartbeat hook treats forced wakes as `message_received_urgent` in the wake delta so agents can distinguish them from normal `messageResponseMode: immediate` wakes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
.changeset/wake-recipient-on-send.md
Normal file
10
.changeset/wake-recipient-on-send.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a sender-side "wake recipient immediately" override for messages. The
|
||||||
|
message composer now offers a checkbox (when sending to an agent) and the
|
||||||
|
`fn_send_message` agent tool gains a `wake_recipient` boolean parameter.
|
||||||
|
When set, the recipient agent is woken on receipt regardless of their own
|
||||||
|
`messageResponseMode` setting. Carried as `metadata.wakeRecipient: true` on
|
||||||
|
the message; ignored when the recipient is a user.
|
||||||
@@ -4289,6 +4289,12 @@ export interface MessageReplyReference {
|
|||||||
export interface MessageMetadata extends Record<string, unknown> {
|
export interface MessageMetadata extends Record<string, unknown> {
|
||||||
/** Optional link to the original message when this message is a reply. */
|
/** Optional link to the original message when this message is a reply. */
|
||||||
replyTo?: MessageReplyReference;
|
replyTo?: MessageReplyReference;
|
||||||
|
/**
|
||||||
|
* If true, the recipient agent is woken immediately on receipt regardless
|
||||||
|
* of their own `messageResponseMode` setting. Sender-initiated override —
|
||||||
|
* use sparingly for urgent messages. Ignored when recipient is a user.
|
||||||
|
*/
|
||||||
|
wakeRecipient?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Message record stored in the system */
|
/** Message record stored in the system */
|
||||||
@@ -4349,10 +4355,11 @@ export interface MessageFilter {
|
|||||||
|
|
||||||
/** Validate mailbox metadata, including reply-link contract when present. */
|
/** Validate mailbox metadata, including reply-link contract when present. */
|
||||||
export function validateMessageMetadata(metadata: MessageMetadata | undefined): void {
|
export function validateMessageMetadata(metadata: MessageMetadata | undefined): void {
|
||||||
if (!metadata || metadata.replyTo === undefined) {
|
if (!metadata) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.replyTo !== undefined) {
|
||||||
if (typeof metadata.replyTo !== "object" || metadata.replyTo === null || Array.isArray(metadata.replyTo)) {
|
if (typeof metadata.replyTo !== "object" || metadata.replyTo === null || Array.isArray(metadata.replyTo)) {
|
||||||
throw new Error("metadata.replyTo must be an object");
|
throw new Error("metadata.replyTo must be an object");
|
||||||
}
|
}
|
||||||
@@ -4360,6 +4367,11 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined):
|
|||||||
if (typeof metadata.replyTo.messageId !== "string" || metadata.replyTo.messageId.trim().length === 0) {
|
if (typeof metadata.replyTo.messageId !== "string" || metadata.replyTo.messageId.trim().length === 0) {
|
||||||
throw new Error("metadata.replyTo.messageId must be a non-empty string");
|
throw new Error("metadata.replyTo.messageId must be a non-empty string");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.wakeRecipient !== undefined && typeof metadata.wakeRecipient !== "boolean") {
|
||||||
|
throw new Error("metadata.wakeRecipient must be a boolean");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mailbox summary for a participant */
|
/** Mailbox summary for a participant */
|
||||||
|
|||||||
@@ -879,6 +879,29 @@
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-composer-field--wake {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-wake-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, var(--text-muted));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-wake-label input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-wake-hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.message-composer-error {
|
.message-composer-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function MessageComposer({
|
|||||||
const [toId, setToId] = useState(recipient?.id ?? "");
|
const [toId, setToId] = useState(recipient?.id ?? "");
|
||||||
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [wakeRecipient, setWakeRecipient] = useState(false);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -56,13 +57,21 @@ export function MessageComposer({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
|
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
|
||||||
|
const includeWake = wakeRecipient && toType === "agent";
|
||||||
|
const metadata =
|
||||||
|
replyContext || includeWake
|
||||||
|
? {
|
||||||
|
...(replyContext ? { replyTo: { messageId: replyContext.messageId } } : {}),
|
||||||
|
...(includeWake ? { wakeRecipient: true } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
{
|
{
|
||||||
toId: toId.trim(),
|
toId: toId.trim(),
|
||||||
toType,
|
toType,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
type: messageType,
|
type: messageType,
|
||||||
...(replyContext ? { metadata: { replyTo: { messageId: replyContext.messageId } } } : {}),
|
...(metadata ? { metadata } : {}),
|
||||||
},
|
},
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
@@ -74,7 +83,7 @@ export function MessageComposer({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
}, [isValid, isSending, toId, toType, content, replyContext, projectId, onSend, addToast]);
|
}, [isValid, isSending, toId, toType, content, wakeRecipient, replyContext, projectId, onSend, addToast]);
|
||||||
|
|
||||||
const handleAgentSelect = useCallback((agentId: string) => {
|
const handleAgentSelect = useCallback((agentId: string) => {
|
||||||
setToId(agentId);
|
setToId(agentId);
|
||||||
@@ -164,6 +173,26 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wake recipient toggle (agents only) */}
|
||||||
|
{toType === "agent" && (
|
||||||
|
<div className="message-composer-field message-composer-field--wake">
|
||||||
|
<label className="message-composer-wake-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wakeRecipient}
|
||||||
|
onChange={(e) => setWakeRecipient(e.target.checked)}
|
||||||
|
data-testid="message-composer-wake"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Wake recipient immediately
|
||||||
|
<span className="message-composer-wake-hint">
|
||||||
|
(overrides their messageResponseMode)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="message-composer-error" data-testid="message-composer-error">
|
<div className="message-composer-error" data-testid="message-composer-error">
|
||||||
|
|||||||
@@ -1149,7 +1149,8 @@ export class HeartbeatMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
||||||
if (runtimeConfig?.messageResponseMode !== "immediate") {
|
const senderForcedWake = message.metadata?.wakeRecipient === true;
|
||||||
|
if (!senderForcedWake && runtimeConfig?.messageResponseMode !== "immediate") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1162,7 @@ export class HeartbeatMonitor {
|
|||||||
void this.executeHeartbeat({
|
void this.executeHeartbeat({
|
||||||
agentId: message.toId,
|
agentId: message.toId,
|
||||||
source: "on_demand",
|
source: "on_demand",
|
||||||
triggerDetail: "wake-on-message",
|
triggerDetail: senderForcedWake ? "wake-on-message-forced" : "wake-on-message",
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
heartbeatLog.warn(`Wake-on-message heartbeat failed for ${message.toId}: ${errorMessage}`);
|
heartbeatLog.warn(`Wake-on-message heartbeat failed for ${message.toId}: ${errorMessage}`);
|
||||||
@@ -1774,6 +1775,7 @@ export class HeartbeatMonitor {
|
|||||||
const deriveWakeReason = (): string => {
|
const deriveWakeReason = (): string => {
|
||||||
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
|
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
|
||||||
if (triggerDetail === "wake-on-message") return "message_received";
|
if (triggerDetail === "wake-on-message") return "message_received";
|
||||||
|
if (triggerDetail === "wake-on-message-forced") return "message_received_urgent";
|
||||||
if (triggerDetail === "wake-on-comment") return "comment_mention";
|
if (triggerDetail === "wake-on-comment") return "comment_mention";
|
||||||
if (triggerDetail === "task-assigned") return "task_assigned";
|
if (triggerDetail === "task-assigned") return "task_assigned";
|
||||||
if (source === "timer") return "timer";
|
if (source === "timer") return "timer";
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export const sendMessageParams = Type.Object({
|
|||||||
reply_to_message_id: Type.Optional(
|
reply_to_message_id: Type.Optional(
|
||||||
Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }),
|
Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }),
|
||||||
),
|
),
|
||||||
|
wake_recipient: Type.Optional(
|
||||||
|
Type.Boolean({
|
||||||
|
description:
|
||||||
|
"If true, wake the recipient agent immediately on receipt regardless of their messageResponseMode. " +
|
||||||
|
"Use sparingly for urgent messages. Ignored when the recipient is a user.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const readMessagesParams = Type.Object({
|
export const readMessagesParams = Type.Object({
|
||||||
@@ -1410,7 +1417,8 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
label: "Send Message",
|
label: "Send Message",
|
||||||
description:
|
description:
|
||||||
"Send a message to another agent or user. The recipient will be woken if they have " +
|
"Send a message to another agent or user. The recipient will be woken if they have " +
|
||||||
"`messageResponseMode: 'immediate'` configured. When replying to an existing message, " +
|
"`messageResponseMode: 'immediate'` configured, or if you set `wake_recipient: true` " +
|
||||||
|
"to override their setting for an urgent message. When replying to an existing message, " +
|
||||||
"include `reply_to_message_id` to preserve threading.",
|
"include `reply_to_message_id` to preserve threading.",
|
||||||
parameters: sendMessageParams,
|
parameters: sendMessageParams,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -1445,6 +1453,15 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wakeRecipient = params.wake_recipient === true && recipient.type === "agent";
|
||||||
|
const metadata =
|
||||||
|
replyToMessageId || wakeRecipient
|
||||||
|
? {
|
||||||
|
...(replyToMessageId ? { replyTo: { messageId: replyToMessageId } } : {}),
|
||||||
|
...(wakeRecipient ? { wakeRecipient: true } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const message = messageStore.sendMessage({
|
const message = messageStore.sendMessage({
|
||||||
fromId: fromAgentId,
|
fromId: fromAgentId,
|
||||||
fromType: "agent",
|
fromType: "agent",
|
||||||
@@ -1452,7 +1469,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
toType: recipient.type,
|
toType: recipient.type,
|
||||||
content,
|
content,
|
||||||
type: messageType,
|
type: messageType,
|
||||||
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
|
...(metadata ? { metadata } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user