FN-8424: route CLI chat replies through inbox mail

Route agent replies to the correct CLI or dashboard mailbox with bounded polling deadlines.

- add reply-parent routing validation and CLI/dashboard inbox selection
- preserve named mailbox conversations while handling per-message reply deadlines
- document chat and inbox interfaces and cover deadline and routing regressions

Files changed:
 .changeset/fn-8424-cli-chat-reply-routing.md       |   7 +
 docs/agents.md                                     |  20 +-
 docs/cli-reference.md                              |  29 +-
 packages/cli/src/bin.ts                            |  15 +-
 packages/cli/src/commands/__tests__/chat.test.ts   | 262 +++++++++---------
 .../cli/src/commands/__tests__/message.test.ts     |  12 +
 packages/cli/src/commands/chat.ts                  | 293 ++++++++++++---------
 packages/cli/src/commands/message.ts               |  18 +-
 ...tools-send-message-recipient-validation.test.ts |  86 +++++-
 packages/engine/src/agent-heartbeat-prompts.ts     |   8 +-
 packages/engine/src/agent-tools.ts                 |  71 +++--
 11 files changed, 523 insertions(+), 298 deletions(-)

Fusion-Task-Id: FN-8424

Fusion-Task-Lineage: 28d0ef88-717e-4f39-8880-64d2fef94706

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 11:26:08 -07:00
parent 00891c225f
commit 3962222863
11 changed files with 527 additions and 302 deletions

View File

@@ -8,15 +8,16 @@ function firstText(result: { content: Array<{ type: string; text?: string }> }):
return first?.type === "text" ? (first.text ?? "") : "";
}
function createMessageStoreHarness() {
function createMessageStoreHarness(parent?: Record<string, unknown> | null) {
const wakeSpy = vi.fn();
const getMessage = vi.fn(async () => parent ?? null);
const sendMessage = vi.fn(async (input: Record<string, unknown>) => {
if (input.toType === "agent") {
await wakeSpy(input);
}
return { id: "msg-1" };
});
return { messageStore: { sendMessage }, sendMessage, wakeSpy };
return { messageStore: { getMessage, sendMessage }, getMessage, sendMessage, wakeSpy };
}
async function executeSend(
@@ -75,6 +76,87 @@ describe("createSendMessageTool recipient validation", () => {
expect(firstText(await executeSend(legacyTool, { to_id: "agent-b", content: "hello", reply_to_message_id: " " }) as never)).toBe("ERROR: reply_to_message_id must be a non-empty string");
});
it("routes an owned CLI parent reply to the CLI user mailbox", async () => {
const parent = {
id: "parent-cli",
fromId: "cli",
fromType: "user",
toId: "agent-a",
toType: "agent",
};
const { messageStore, sendMessage } = createMessageStoreHarness(parent);
const tool = createSendMessageTool(messageStore as never, "agent-a");
// Heartbeat guidance intentionally names the sender explicitly; it must still
// preserve the parent user's type rather than treating `cli` as an agent.
const result = await executeSend(tool, { content: "received", reply_to_message_id: "parent-cli", to_id: "cli" });
expect(firstText(result as never)).toContain("Message sent to cli");
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
toId: "cli",
toType: "user",
type: "agent-to-user",
metadata: { replyTo: { messageId: "parent-cli" } },
}));
});
it("routes an owned dashboard parent reply to the dashboard mailbox", async () => {
const parent = {
id: "parent-dashboard",
fromId: "dashboard",
fromType: "user",
toId: "agent-a",
toType: "agent",
};
const { messageStore, sendMessage } = createMessageStoreHarness(parent);
const tool = createSendMessageTool(messageStore as never, "agent-a");
await executeSend(tool, { content: "received", reply_to_message_id: "parent-dashboard" });
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
toId: "dashboard",
toType: "user",
type: "agent-to-user",
}));
});
it("allows an explicit alternate recipient without inheriting a foreign parent", async () => {
const foreignParent = {
id: "parent-foreign",
fromId: "cli",
fromType: "user",
toId: "agent-b",
toType: "agent",
};
const { messageStore, sendMessage } = createMessageStoreHarness(foreignParent);
const tool = createSendMessageTool(messageStore as never, "agent-a");
await executeSend(tool, { content: "forward", reply_to_message_id: "parent-foreign", to_id: "agent-c" });
// A different explicit ID is a forward: without type it keeps the legacy
// agent-to-agent default instead of inheriting the foreign parent's user type.
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ toId: "agent-c", toType: "agent", type: "agent-to-agent" }));
});
it("rejects foreign or missing parents when no explicit recipient is supplied", async () => {
const foreignParent = {
id: "parent-foreign",
fromId: "cli",
fromType: "user",
toId: "agent-b",
toType: "agent",
};
const foreignHarness = createMessageStoreHarness(foreignParent);
const foreignTool = createSendMessageTool(foreignHarness.messageStore as never, "agent-a");
const missingHarness = createMessageStoreHarness();
const missingTool = createSendMessageTool(missingHarness.messageStore as never, "agent-a");
expect(firstText(await executeSend(foreignTool, { content: "nope", reply_to_message_id: "parent-foreign" }) as never)).toMatch(/^ERROR: reply_to_message_id/);
expect(foreignHarness.sendMessage).not.toHaveBeenCalled();
expect(firstText(await executeSend(missingTool, { content: "nope", reply_to_message_id: "missing" }) as never)).toMatch(/^ERROR: reply_to_message_id/);
expect(missingHarness.sendMessage).not.toHaveBeenCalled();
});
it("does not report delivery when recipient validation is unavailable", async () => {
const failedLookupHarness = createMessageStoreHarness();
const failedLookupTool = createSendMessageTool(failedLookupHarness.messageStore as never, "agent-a", {

View File

@@ -107,7 +107,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
2. For each message, classify it: informational, question, request, or escalation.
3. Take one concrete action per actionable message:
- If the message requires a response, use fn_send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output.
- When replying, include 'reply_to_message_id' and set 'to_id' to the exact [from: type:id] ID from fn_read_messages (including cli). Omit 'to_id' only to use the safe reply-to-parent default for a message addressed to you.
- If the message is informational, acknowledge it by logging with fn_task_log.
- If the message requests net-new work, first check whether an open task already covers it; idle/no-task heartbeats may create only with approved Feature → Slice → Milestone → Mission lineage.
- If ownership is clear and an agent is available, delegate only approved mission-linked work using fn_delegate_task.
@@ -123,7 +123,7 @@ Example flow:
When sending messages:
- Be concise and clear about what you need or what you've done.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked, and use the exact sender ID reported by fn_read_messages (including cli).
- Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`;
@@ -209,7 +209,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
1. If fn_read_messages is available, use it to check your inbox for unread messages.
2. Review each message and determine the appropriate action:
- If the message requires a response and fn_send_message is available, use fn_send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output.
- When replying, include 'reply_to_message_id' and set 'to_id' to the exact [from: type:id] ID from fn_read_messages (including cli). Omit 'to_id' only to use the safe reply-to-parent default for a message addressed to you.
- If the message is informational, acknowledge it and respond via fn_send_message when appropriate.
- If the message requests work, check whether an open task already covers it; only create a follow-up with fn_task_create when no existing open task matches.
- If the request has a clear owner and fn_delegate_task is available, delegate it directly.
@@ -221,7 +221,7 @@ Example flow:
When sending messages:
- Be concise and clear about what you need or what you've done.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked, and use the exact sender ID reported by fn_read_messages (including cli).
- Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`;

View File

@@ -450,14 +450,14 @@ export const deleteAgentParams = Type.Object({
});
export const sendMessageParams = Type.Object({
to_id: Type.String({ description: "Recipient ID (agent ID or user ID, depending on message type)" }),
to_id: Type.Optional(Type.String({ description: "Recipient ID. When replying, omit to deliver to the parent sender; otherwise provide the exact ID from fn_read_messages." })),
content: Type.String({ description: "Message body (1-2000 characters)" }),
type: Type.Optional(Type.Union([
Type.Literal("agent-to-agent"),
Type.Literal("agent-to-user"),
], { description: "Message type (defaults to 'agent-to-agent')" })),
], { description: "Message type. Required for explicit non-dashboard user recipients; inferred from a valid reply parent when omitted." })),
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. Parent-based recipient inference is allowed only when that message was addressed to you." }),
),
});
@@ -4697,8 +4697,9 @@ export function createSendMessageTool(
label: "Send Message",
description:
"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, " +
"include `reply_to_message_id` to preserve threading.",
"`messageResponseMode: 'immediate'` configured. When replying, include `reply_to_message_id`; omit " +
"`to_id` to reply to that message's sender only when the parent was addressed to you. Otherwise provide " +
"the exact recipient ID and appropriate type explicitly.",
parameters: sendMessageParams,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: async (_id: string, params: Static<typeof sendMessageParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
@@ -4717,13 +4718,6 @@ export function createSendMessageTool(
}
try {
const inferredDashboardRecipient = normalizeMessageParticipant(params.to_id, "user");
const messageType = params.type
?? (inferredDashboardRecipient.id === DASHBOARD_USER_ID ? "agent-to-user" : "agent-to-agent");
const recipientType: "user" | "agent" = messageType === "agent-to-user" ? "user" : "agent";
const recipient = recipientType === "user"
? normalizeMessageParticipant(params.to_id, recipientType)
: { id: params.to_id, type: recipientType };
const replyToMessageId = params.reply_to_message_id?.trim();
if (params.reply_to_message_id !== undefined && !replyToMessageId) {
@@ -4733,6 +4727,53 @@ export function createSendMessageTool(
};
}
/*
FNXC:CliChatReplyRouting 2026-07-20-12:00:
CLI mail belongs to `cli`, while dashboard mail belongs to `dashboard`.
FN-8424 requires a reply to a message addressed to this agent to default
to that parent's sender, preserving both mailbox identities. A foreign,
missing, or non-agent-addressed parent must never supply routing data:
agents may still intentionally name an explicit recipient, but cannot
launder a recipient through another agent's reply thread.
*/
const parent = replyToMessageId ? await messageStore.getMessage(replyToMessageId) : undefined;
const parentWasAddressedToSender = parent != null
&& normalizeMessageParticipant(parent.toId, parent.toType).id === fromAgentId
&& parent.toType === "agent";
if (replyToMessageId && !parentWasAddressedToSender && !params.to_id?.trim()) {
return {
content: [{ type: "text" as const, text: "ERROR: reply_to_message_id does not reference a message addressed to this agent; provide an explicit to_id to send intentionally" }],
details: {},
};
}
const parentRecipient = parentWasAddressedToSender && parent
? normalizeMessageParticipant(parent.fromId, parent.fromType)
: undefined;
const explicitRecipientId = params.to_id?.trim();
const recipientId = explicitRecipientId ?? parentRecipient?.id;
// FNXC:CliChatReplyRouting 2026-07-20-12:00: An explicit recipient that names the valid parent sender remains a reply, so it inherits that sender's participant type (notably `cli` -> user). A different explicit ID is an intentional forward and retains the legacy agent-to-agent default unless its type is stated.
const explicitTargetsParent = explicitRecipientId != null
&& parentRecipient != null
&& normalizeMessageParticipant(explicitRecipientId, parentRecipient.type).id === parentRecipient.id;
const recipientParticipantType = !explicitRecipientId || explicitTargetsParent
? parentRecipient?.type
: undefined;
if (!recipientId) {
return {
content: [{ type: "text" as const, text: "ERROR: to_id is required unless replying to a message addressed to this agent" }],
details: {},
};
}
const inferredDashboardRecipient = normalizeMessageParticipant(recipientId, "user");
const messageType = params.type
?? (recipientParticipantType === "user" || inferredDashboardRecipient.id === DASHBOARD_USER_ID ? "agent-to-user" : "agent-to-agent");
const recipientType: "user" | "agent" = messageType === "agent-to-user" ? "user" : "agent";
const recipient = recipientType === "user"
? normalizeMessageParticipant(recipientId, recipientType)
: { id: recipientId, type: recipientType };
/*
FNXC:AgentMessaging 2026-07-28-12:10:
Agent-to-agent sends must reject missing recipients rather than store an unread, undeliverable message and report false delivery success. Use async getAgent instead of getCachedAgent because the synchronous cache always returns null in PostgreSQL mode. A lookup failure is validation-unavailable and must block the send; only a successful lookup may establish delivery confidence.
@@ -4743,13 +4784,13 @@ export function createSendMessageTool(
resolvedRecipient = await options.agentStore.getAgent(recipient.id);
} catch {
return {
content: [{ type: "text" as const, text: `ERROR: Recipient agent '${params.to_id}' could not be validated — message not sent` }],
content: [{ type: "text" as const, text: `ERROR: Recipient agent '${recipient.id}' could not be validated — message not sent` }],
details: {},
};
}
if (resolvedRecipient == null) {
return {
content: [{ type: "text" as const, text: `ERROR: Recipient agent '${params.to_id}' does not exist — message not sent` }],
content: [{ type: "text" as const, text: `ERROR: Recipient agent '${recipient.id}' does not exist — message not sent` }],
details: {},
};
}
@@ -4788,7 +4829,7 @@ export function createSendMessageTool(
return {
content: [{
type: "text" as const,
text: `Message sent to ${recipient.id === DASHBOARD_USER_ID ? DASHBOARD_USER_ID : params.to_id} (ID: ${result.value.id})`,
text: `Message sent to ${recipient.id} (ID: ${result.value.id})`,
}],
details: { messageId: result.value.id },
};