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

@@ -448,13 +448,14 @@ PR:
Export Fusion agents to an Agent Companies package directory
(agent skills assigned via metadata.skills affect execution-time tools)
fn agent mailbox <id> View an agent's mailbox
fn message inbox List inbox messages
fn message inbox [--user <cli|dashboard>]
List CLI or dashboard operator inbox messages
fn message outbox List sent messages
fn message send <agent-id> <msg> Send a message to an agent
fn message read <id> Read a specific message
fn message delete <id> Delete a message
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]
Named mailbox conversation; delivers to agent inbox (not a chat room)
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--reply-timeout-ms <n>] [--conversation-id <id>]
Named mailbox conversation with deadline-bounded inbox replies
fn backup --create Create a database backup immediately
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
@@ -2069,7 +2070,12 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "inbox": {
await runMessageInbox(projectName);
const inboxUser = getFlagValue(args.slice(2), "--user");
if (inboxUser !== undefined && inboxUser !== "cli" && inboxUser !== "dashboard") {
console.error("Usage: fn message inbox [--user <cli|dashboard>]");
process.exit(1);
}
await runMessageInbox(projectName, inboxUser);
break;
}
case "outbox": {
@@ -2119,6 +2125,7 @@ async function main() {
once: parsed.once,
nonInteractive: parsed.nonInteractive,
pollIntervalMs: parsed.pollIntervalMs,
replyTimeoutMs: parsed.replyTimeoutMs,
conversationId: parsed.conversationId,
input,
});

View File

@@ -1,162 +1,172 @@
import { PassThrough, Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockGetAgent,
mockGetConversation,
mockGetInbox,
mockSendMessage,
mockMarkAsRead,
mockClose,
mockCleanup,
} = vi.hoisted(() => ({
mockGetAgent: vi.fn(),
mockGetConversation: vi.fn(),
mockGetInbox: vi.fn(),
mockSendMessage: vi.fn(),
mockMarkAsRead: vi.fn(),
mockClose: vi.fn(),
mockCleanup: vi.fn(),
}));
const mockGetAgent = vi.fn();
const mockGetConversation = vi.fn();
const mockGetInbox = vi.fn();
const mockSendMessage = vi.fn();
const mockMarkAsRead = vi.fn();
const mockClose = vi.fn();
vi.mock("@fusion/core", () => ({
AgentStore: vi.fn(function () {
return { init: vi.fn(), getAgent: mockGetAgent, close: mockClose };
}),
MessageStore: vi.fn(function () {
return {
getConversation: mockGetConversation,
getInbox: mockGetInbox,
sendMessage: mockSendMessage,
markAsRead: mockMarkAsRead,
};
}),
}));
vi.mock("@fusion/core", () => {
class AgentStore {
init = vi.fn(async () => undefined);
getAgent = mockGetAgent;
close = mockClose;
}
class MessageStore {
getConversation = mockGetConversation;
getInbox = mockGetInbox;
sendMessage = mockSendMessage;
markAsRead = mockMarkAsRead;
}
return { AgentStore, MessageStore, DASHBOARD_USER_ID: "dashboard" };
});
vi.mock("../../project-context.js", () => ({
resolveAgentStoreBase: vi.fn().mockResolvedValue({
rootDir: "/tmp/fusion-cli-chat-test",
resolveAgentStoreBase: vi.fn(async () => ({
rootDir: "/tmp/chat-test",
asyncLayer: {},
cleanup: mockCleanup,
}),
cleanup: vi.fn(async () => undefined),
})),
}));
import { runChatInteractive } from "../chat.js";
import { runMessageSend } from "../message.js";
function outputBuffer(): { output: PassThrough; read: () => string } {
function outputBuffer() {
const output = new PassThrough();
const chunks: Buffer[] = [];
output.on("data", (chunk: Buffer) => chunks.push(chunk));
return { output, read: () => Buffer.concat(chunks).toString("utf8") };
let text = "";
output.on("data", (chunk: Buffer) => { text += chunk.toString(); });
return { output, text: () => text };
}
describe("runChatInteractive mailbox conversation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAgent.mockResolvedValue({ id: "agent-001" });
mockGetConversation.mockResolvedValue([]);
mockGetInbox.mockResolvedValue([]);
mockSendMessage.mockResolvedValue({ id: "msg-001" });
mockCleanup.mockResolvedValue(undefined);
});
function reply(id: string, content: string, replyTo?: string) {
return {
id,
fromId: "agent-a",
fromType: "agent" as const,
toId: "cli",
toType: "user" as const,
content,
type: "agent-to-user" as const,
read: false,
...(replyTo ? { metadata: { replyTo: { messageId: replyTo } } } : {}),
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
};
}
it("stamps sequential default sends with one stable cli-chat conversation id", async () => {
for (const content of ["first", "second"]) {
await runChatInteractive("agent-001", {
once: true,
nonInteractive: true,
input: Readable.from(content),
output: outputBuffer().output,
replyTimeoutMs: 0,
});
}
beforeEach(() => {
mockGetAgent.mockResolvedValue({ id: "agent-a" });
mockGetConversation.mockResolvedValue([]);
mockGetInbox.mockResolvedValue([]);
mockSendMessage.mockImplementation(async () => ({ id: `outbound-${mockSendMessage.mock.calls.length}` }));
mockMarkAsRead.mockResolvedValue(undefined);
});
expect(mockSendMessage).toHaveBeenCalledTimes(2);
const metadata = mockSendMessage.mock.calls.map(([input]) => input.metadata);
expect(metadata).toEqual([
{ wakeRecipient: true, kind: "cli-chat", conversationId: "cli-chat:cli:agent-001" },
{ wakeRecipient: true, kind: "cli-chat", conversationId: "cli-chat:cli:agent-001" },
]);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("renders only history from its conversation id or replies to its messages", async () => {
mockGetConversation.mockResolvedValue([
{ id: "other", fromId: "agent-001", fromType: "agent", content: "other thread", type: "agent-to-user", read: false, createdAt: "2026-07-20T00:00:00.000Z", updatedAt: "2026-07-20T00:00:00.000Z", metadata: { conversationId: "other-thread" } },
{ id: "thread-message", fromId: "user:cli", fromType: "user", content: "thread start", type: "user-to-agent", read: true, createdAt: "2026-07-20T00:00:01.000Z", updatedAt: "2026-07-20T00:00:01.000Z", metadata: { conversationId: "custom-thread" } },
{ id: "thread-reply", fromId: "agent-001", fromType: "agent", content: "thread reply", type: "agent-to-user", read: false, createdAt: "2026-07-20T00:00:02.000Z", updatedAt: "2026-07-20T00:00:02.000Z", metadata: { replyTo: { messageId: "thread-message" } } },
]);
const buffer = outputBuffer();
describe("runChatInteractive", () => {
it("prints a reply delivered to the CLI mailbox for a one-shot CLI message", async () => {
const { output, text } = outputBuffer();
const replies = [reply("reply-1", "board review complete", "outbound-1")];
mockGetInbox.mockImplementation(async () => replies);
await runChatInteractive("agent-001", {
const code = await runChatInteractive("agent-a", {
once: true,
nonInteractive: true,
input: Readable.from(""),
output: buffer.output,
conversationId: "custom-thread",
});
expect(buffer.read()).toContain("thread start");
expect(buffer.read()).toContain("thread reply");
expect(buffer.read()).not.toContain("other thread");
});
it("renders only replies associated with the current conversation and leaves other mail unread", async () => {
mockGetInbox.mockResolvedValue([
{ id: "unrelated", fromId: "agent-001", fromType: "agent", content: "unrelated", type: "agent-to-user", read: false, createdAt: "2026-07-20T00:00:00.000Z", updatedAt: "2026-07-20T00:00:00.000Z" },
{ id: "reply", fromId: "agent-001", fromType: "agent", content: "associated", type: "agent-to-user", read: false, createdAt: "2026-07-20T00:00:01.000Z", updatedAt: "2026-07-20T00:00:01.000Z", metadata: { replyTo: { messageId: "msg-001" } } },
]);
const buffer = outputBuffer();
await runChatInteractive("agent-001", {
once: true,
nonInteractive: true,
input: Readable.from("hello"),
output: buffer.output,
replyTimeoutMs: 5,
input: Readable.from(["review the board"]),
output,
pollIntervalMs: 1,
replyTimeoutMs: 100,
});
expect(buffer.read()).toContain("associated");
expect(buffer.read()).not.toContain("unrelated");
expect(mockMarkAsRead).toHaveBeenCalledWith("reply");
expect(mockMarkAsRead).not.toHaveBeenCalledWith("unrelated");
expect(code).toBe(0);
expect(mockSendMessage).toHaveBeenCalledWith(expect.objectContaining({ fromId: "cli", toId: "agent-a" }));
expect(text()).toContain("board review complete");
expect(mockMarkAsRead).toHaveBeenCalledWith("reply-1");
});
it("uses an explicit conversation id and names inbox delivery in the session banner", async () => {
const buffer = outputBuffer();
await runChatInteractive("agent-001", {
it("returns at the reply deadline rather than a large poll interval", async () => {
vi.useFakeTimers();
const { output } = outputBuffer();
const command = runChatInteractive("agent-a", {
once: true,
nonInteractive: true,
input: Readable.from("hello"),
output: buffer.output,
conversationId: "custom-thread",
replyTimeoutMs: 0,
input: Readable.from(["ping"]),
output,
pollIntervalMs: 300_000,
replyTimeoutMs: 5_000,
});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_001);
await expect(command).resolves.toBe(0);
});
it("expires one interactive pending reply but keeps polling for a later reply", async () => {
vi.useFakeTimers();
const input = new PassThrough();
const { output, text } = outputBuffer();
const replies: ReturnType<typeof reply>[] = [];
mockGetInbox.mockImplementation(async () => replies);
const command = runChatInteractive("agent-a", {
input,
output,
pollIntervalMs: 300_000,
replyTimeoutMs: 5_000,
});
await vi.advanceTimersByTimeAsync(0);
input.write("first request\n");
await vi.advanceTimersByTimeAsync(0);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_001);
expect(text()).toContain("No reply within 5s for: first request");
input.write("second request\n");
await vi.advanceTimersByTimeAsync(0);
expect(mockSendMessage).toHaveBeenCalledTimes(2);
replies.push(reply("reply-2", "second answer", "outbound-2"));
// The only scheduled poll is capped by the second request's own deadline,
// not the 300-second normal interval.
await vi.advanceTimersByTimeAsync(5_001);
expect(text()).toContain("second answer");
expect(text()).not.toContain("No reply within 5s for: second request");
input.end("/exit\n");
await expect(command).resolves.toBe(0);
});
});
describe("named mailbox conversations", () => {
it("stamps a stable conversation ID and leaves unrelated agent mail unread", async () => {
const { output, text } = outputBuffer();
mockGetInbox.mockResolvedValue([
reply("other-thread", "other mailbox traffic"),
reply("thread-reply", "threaded answer", "outbound-1"),
]);
await runChatInteractive("agent-a", {
once: true,
nonInteractive: true,
input: Readable.from(["status"]),
output,
pollIntervalMs: 1,
replyTimeoutMs: 100,
});
expect(mockSendMessage).toHaveBeenCalledWith(expect.objectContaining({
metadata: { wakeRecipient: true, kind: "cli-chat", conversationId: "custom-thread" },
metadata: { wakeRecipient: true, kind: "cli-chat", conversationId: "cli-chat:cli:agent-a" },
}));
expect(buffer.read()).toContain("Mailbox conversation");
expect(buffer.read()).toContain("agent inbox");
expect(buffer.read()).toContain("conversation-id: custom-thread");
});
it("explains mailbox delivery and the conversation id in REPL help", async () => {
const buffer = outputBuffer();
await runChatInteractive("agent-001", {
input: Readable.from("/help\n/exit\n"),
output: buffer.output,
pollIntervalMs: 1,
});
expect(buffer.read()).toContain("Mailbox delivery to the agent inbox");
expect(buffer.read()).toContain("conversation-id: cli-chat:cli:agent-001");
});
it("keeps fn message send as an unstamped one-shot message", async () => {
await runMessageSend("agent-001", "ordinary mail");
expect(mockSendMessage).toHaveBeenCalledWith(expect.not.objectContaining({ metadata: expect.anything() }));
expect(text()).toContain("threaded answer");
expect(text()).not.toContain("other mailbox traffic");
expect(mockMarkAsRead).toHaveBeenCalledWith("thread-reply");
expect(mockMarkAsRead).not.toHaveBeenCalledWith("other-thread");
});
});

View File

@@ -32,6 +32,7 @@ vi.mock("@fusion/core", () => {
};
return {
createDatabase: vi.fn().mockReturnValue(mockDb),
DASHBOARD_USER_ID: "dashboard",
MessageStore: makeConstructibleMock(() => ({
getInbox: mockGetInbox,
getOutbox: mockGetOutbox,
@@ -125,6 +126,17 @@ describe("runMessageInbox", () => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No messages"));
});
it("lists the distinct dashboard operator mailbox when requested", async () => {
mockGetMailbox.mockReturnValue({ unreadCount: 1, ownerId: "dashboard", ownerType: "user" });
mockGetInbox.mockReturnValue([{ ...mockMessage, toId: "dashboard" }]);
await runMessageInbox(undefined, "dashboard");
expect(mockGetMailbox).toHaveBeenCalledWith("dashboard", "user");
expect(mockGetInbox).toHaveBeenCalledWith("dashboard", "user", { limit: 20 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Dashboard Inbox"));
});
it("should show unread marker for unread messages", async () => {
await runMessageInbox();

View File

@@ -6,12 +6,12 @@ import { createInterface } from "node:readline/promises";
const MAX_MESSAGE_LENGTH = 8192;
const DEFAULT_POLL_MS = 1000;
const DEFAULT_REPLY_TIMEOUT_MS = 60_000;
const HISTORY_LIMIT = 20;
/**
* FNXC:CliChatConversation 2026-07-20-12:00:
* CLI chats use MessageStore's project-scoped mailbox transport, so the stable
* CLI-user/agent pair is sufficient to resume a named thread within a project.
* CLI chats use a durable MessageStore thread per CLI-user/agent pair.
*/
export function buildCliChatConversationId(agentId: string, override?: string): string {
return override ?? `cli-chat:${CLI_USER_ID}:${agentId}`;
@@ -28,50 +28,48 @@ export interface ChatInteractiveOptions {
output?: NodeJS.WritableStream;
}
export type ChatCliArgs = Pick<ChatInteractiveOptions, "conversationId" | "pollIntervalMs" | "once" | "nonInteractive"> & {
interface PendingReply {
outboundMessageId: string;
sentAt: number;
deadlineAt: number;
preview: string;
}
export type ChatCliArgs = Pick<ChatInteractiveOptions, "conversationId" | "pollIntervalMs" | "replyTimeoutMs" | "once" | "nonInteractive"> & {
agentId: string;
contentArg: string;
};
/** Parse chat-only argv after the `chat` command for dispatch and unit tests. */
export function parseChatCliArgs(args: string[]): ChatCliArgs | { error: string } {
const usage = "Usage: fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]";
const usage = "Usage: fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--reply-timeout-ms <n>] [--conversation-id <id>]";
const agentId = args[0];
if (!agentId) return { error: usage };
const pollIdx = args.indexOf("--poll-ms");
const pollValue = pollIdx === -1 ? undefined : args[pollIdx + 1];
const pollIntervalMs = pollValue === undefined ? undefined : Number.parseInt(pollValue, 10);
if (pollIdx !== -1 && (!pollValue || pollValue.startsWith("--") || !Number.isFinite(pollIntervalMs) || (pollIntervalMs ?? 0) <= 0)) {
return { error: usage };
}
const readPositiveFlag = (flag: string) => {
const index = args.indexOf(flag);
const value = index === -1 ? undefined : args[index + 1];
const parsed = value === undefined ? undefined : Number.parseInt(value, 10);
return { index, parsed, valid: index === -1 || (!!value && !value.startsWith("--") && Number.isFinite(parsed) && (parsed ?? 0) > 0) };
};
const poll = readPositiveFlag("--poll-ms");
const timeout = readPositiveFlag("--reply-timeout-ms");
if (!poll.valid || !timeout.valid) return { error: usage };
let conversationId: string | undefined;
for (let index = 1; index < args.length; index += 1) {
if (args[index] !== "--conversation-id") continue;
const value = args[index + 1];
// FNXC:CliChatConversation 2026-07-20-14:30: Every occurrence must have a value. A first valid flag must not hide a later incomplete flag and silently route mail to the wrong thread.
if (conversationId !== undefined || !value || value.startsWith("--")) {
return { error: usage };
}
if (conversationId !== undefined || !value || value.startsWith("--")) return { error: usage };
conversationId = value;
index += 1;
}
const filteredArgs = args.slice(1).filter((arg, index, values) => {
if (arg === "--once" || arg === "--non-interactive" || arg === "--poll-ms" || arg === "--conversation-id") return false;
if (index > 0 && (values[index - 1] === "--poll-ms" || values[index - 1] === "--conversation-id")) return false;
return true;
});
const contentArg = filteredArgs.join(" ").trim();
return {
agentId,
conversationId,
pollIntervalMs: pollIdx === -1 ? undefined : pollIntervalMs,
contentArg,
const flagsWithValues = new Set(["--poll-ms", "--reply-timeout-ms", "--conversation-id"]);
const contentArg = args.slice(1).filter((arg, index, values) =>
arg !== "--once" && arg !== "--non-interactive" && !flagsWithValues.has(arg)
&& !(index > 0 && flagsWithValues.has(values[index - 1] ?? "")),
).join(" ").trim();
return { agentId, conversationId, pollIntervalMs: poll.parsed, replyTimeoutMs: timeout.parsed, contentArg,
once: args.includes("--once") || contentArg.length > 0,
nonInteractive: args.includes("--non-interactive") || contentArg.length > 0,
};
nonInteractive: args.includes("--non-interactive") || contentArg.length > 0 };
}
/*
@@ -103,11 +101,20 @@ async function createAgentStore(projectName?: string): Promise<{ store: AgentSto
}
}
function parsePositiveMs(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && (value ?? 0) > 0 ? value! : fallback;
}
function parsePollMs(options: ChatInteractiveOptions): number {
const envValue = process.env.FUSION_CHAT_POLL_MS;
const envPollMs = envValue ? Number.parseInt(envValue, 10) : Number.NaN;
const candidate = options.pollIntervalMs ?? (Number.isFinite(envPollMs) ? envPollMs : DEFAULT_POLL_MS);
return Number.isFinite(candidate) && candidate > 0 ? candidate : DEFAULT_POLL_MS;
return parsePositiveMs(options.pollIntervalMs ?? envPollMs, DEFAULT_POLL_MS);
}
function parseReplyTimeoutMs(options: ChatInteractiveOptions): number {
const envValue = process.env.FUSION_CHAT_REPLY_TIMEOUT_MS;
const envTimeoutMs = envValue ? Number.parseInt(envValue, 10) : Number.NaN;
return parsePositiveMs(options.replyTimeoutMs ?? envTimeoutMs, DEFAULT_REPLY_TIMEOUT_MS);
}
function printMessage(output: NodeJS.WritableStream, message: Message): void {
@@ -124,16 +131,13 @@ function printConversationTail(output: NodeJS.WritableStream, messages: Message[
}
output.write("\nRecent conversation:\n\n");
for (const message of messages) {
printMessage(output, message);
}
for (const message of messages) printMessage(output, message);
}
/**
* FNXC:CliChatConversation 2026-07-20-14:30:
* MessageStore queries are participant-wide, not conversation-scoped. A mailbox
* message belongs to this CLI thread only when it carries this id or replies to
* an already-known thread message; unassociated agent mail remains unread.
* Participant queries are not conversation-scoped; only thread-tagged mail or
* replies to known thread messages may be displayed or marked read.
*/
function collectConversationMessages(messages: Message[], conversationId: string, threadMessageIds = new Set<string>()): Message[] {
const includedIds = new Set<string>();
@@ -143,14 +147,10 @@ function collectConversationMessages(messages: Message[], conversationId: string
for (const message of messages) {
if (includedIds.has(message.id)) continue;
const directMatch = message.metadata?.conversationId === conversationId;
const replyMatch = typeof message.metadata?.replyTo?.messageId === "string"
&& threadMessageIds.has(message.metadata.replyTo.messageId);
const replyMatch = typeof message.metadata?.replyTo?.messageId === "string" && threadMessageIds.has(message.metadata.replyTo.messageId);
if (!threadMessageIds.has(message.id) && !directMatch && !replyMatch) continue;
includedIds.add(message.id);
if (!threadMessageIds.has(message.id)) {
threadMessageIds.add(message.id);
changed = true;
}
if (!threadMessageIds.has(message.id)) { threadMessageIds.add(message.id); changed = true; }
}
}
return messages.filter((message) => includedIds.has(message.id));
@@ -160,22 +160,64 @@ function isConversationReply(message: Message, conversationId: string, threadMes
return collectConversationMessages([message], conversationId, threadMessageIds).length > 0;
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
function isCliReply(message: Message, agentId: string): boolean {
return message.fromId === agentId
&& message.fromType === "agent"
&& message.toId === CLI_USER_ID
&& message.toType === "user";
}
function replyToId(message: Message): string | undefined {
return message.metadata?.replyTo?.messageId;
}
function findPendingReply(pendingReplies: Map<string, PendingReply>, message: Message): PendingReply | undefined {
const threaded = replyToId(message);
if (threaded) return pendingReplies.get(threaded);
// Replies without metadata retain useful behavior by consuming the oldest open request once.
return [...pendingReplies.values()].sort((a, b) => a.sentAt - b.sentAt)[0];
}
async function getChatReplies(
messageStore: Awaited<ReturnType<typeof createMessageStore>>["store"],
agentId: string,
conversationId: string,
threadMessageIds: Set<string>,
): Promise<Message[]> {
// Conversation lookup sees replies already marked read by another CLI process; inbox keeps the normal unread path cheap.
const [conversation, inbox] = await Promise.all([
messageStore.getConversation({ id: CLI_USER_ID, type: "user" }, { id: agentId, type: "agent" }, { limit: 50 }),
messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 }),
]);
const messages = new Map<string, Message>();
for (const message of [...conversation, ...inbox]) {
if (isCliReply(message, agentId) && isConversationReply(message, conversationId, threadMessageIds)) messages.set(message.id, message);
}
return [...messages.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
function sleep(ms: number, signal?: AbortSignal, wake?: Promise<void>): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
const timer = setTimeout(done, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new Error("aborted"));
};
if (signal.aborted) {
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
resolve();
}
if (signal?.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
signal?.addEventListener("abort", onAbort, { once: true });
void wake?.then(done);
});
}
async function waitForReply(
export async function waitForReply(
messageStore: Awaited<ReturnType<typeof createMessageStore>>["store"],
agentId: string,
printedIds: Set<string>,
@@ -185,27 +227,48 @@ async function waitForReply(
conversationId: string,
threadMessageIds: Set<string>,
): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (!isConversationReply(message, conversationId, threadMessageIds)) continue;
const deadlineAt = Date.now() + timeoutMs;
while (true) {
for (const message of await getChatReplies(messageStore, agentId, conversationId, threadMessageIds)) {
if (printedIds.has(message.id)) continue;
printedIds.add(message.id);
printMessage(output, message);
await messageStore.markAsRead(message.id);
return true;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const remainingTimeoutMs = Math.max(0, deadlineAt - Date.now());
if (remainingTimeoutMs === 0) return false;
/*
FNXC:CliChatReplyRouting 2026-07-20-12:00:
A user-selected poll interval must not postpone a one-shot reply timeout.
Bound every sleep to the remaining deadline so --poll-ms 300000 still exits
at the configured reply timeout rather than minutes later.
*/
await sleep(Math.min(pollIntervalMs, remainingTimeoutMs));
}
return false;
}
function expirePendingReplies(pendingReplies: Map<string, PendingReply>, output: NodeJS.WritableStream, now: number): void {
for (const [id, pending] of pendingReplies) {
if (now < pending.deadlineAt) continue;
pendingReplies.delete(id);
output.write(`No reply within ${Math.ceil((pending.deadlineAt - pending.sentAt) / 1000)}s for: ${pending.preview}\n`);
}
}
function nearestPendingSleep(pendingReplies: Map<string, PendingReply>, pollIntervalMs: number, now: number): number {
const nearestDeadline = Math.min(...[...pendingReplies.values()].map((pending) => pending.deadlineAt));
return Number.isFinite(nearestDeadline)
? Math.min(pollIntervalMs, Math.max(0, nearestDeadline - now))
: pollIntervalMs;
}
export async function runChatInteractive(agentId: string, options: ChatInteractiveOptions = {}): Promise<number> {
const output = options.output ?? process.stdout;
const input = options.input ?? process.stdin;
const pollIntervalMs = parsePollMs(options);
const replyTimeoutMs = parseReplyTimeoutMs(options);
const conversationId = buildCliChatConversationId(agentId, options.conversationId);
const ownedAgentStore = await createAgentStore(options.project);
@@ -224,72 +287,79 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
const printedIds = new Set<string>();
const conversation = await messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
);
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
);
const threadMessageIds = new Set<string>();
const tail = collectConversationMessages(conversation, conversationId, threadMessageIds).slice(-HISTORY_LIMIT);
for (const message of tail) printedIds.add(message.id);
/*
FNXC:CliChatConversation 2026-07-20-12:00:
The CLI must name MessageStore inbox delivery honestly: this is a resumable
mailbox conversation, not a dashboard ChatView session or multi-agent room.
*/
output.write(`Mailbox conversation with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`);
output.write(`conversation-id: ${conversationId}\n`);
output.write("Delivery: agent inbox (fn_read_messages). Not a dashboard chat session or multi-agent room.\n");
output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
printConversationTail(output, tail);
const runOnce = options.once === true;
if (runOnce) {
if (options.once === true) {
const content = await readSingleMessage(input, output, options.nonInteractive);
if (!content.trim()) return 0;
if (content.length > MAX_MESSAGE_LENGTH) {
console.error(`Message too long; max ${MAX_MESSAGE_LENGTH} chars`);
return 0;
}
const sentMessage = await messageStore.sendMessage({
const outbound = await messageStore.sendMessage({
fromId: CLI_USER_ID,
fromType: "user",
toId: agentId,
toType: "agent",
content,
type: "user-to-agent",
/*
FNXC:CliChatConversation 2026-07-20-12:00:
Keep wake-on-message inbox delivery for durable agents, but stamp every
CLI chat turn so agents can recognize the resumable mailbox thread.
*/
metadata: { wakeRecipient: true, kind: "cli-chat", conversationId },
});
threadMessageIds.add(outbound.id);
output.write(`you → ${agentId}: ${content}\n`);
const timeoutMs = options.replyTimeoutMs ?? Math.max(pollIntervalMs * 10, 30_000);
threadMessageIds.add(sentMessage.id);
const replied = await waitForReply(messageStore, agentId, printedIds, output, pollIntervalMs, timeoutMs, conversationId, threadMessageIds);
if (!replied) {
console.error(`No reply within ${Math.ceil(timeoutMs / 1000)}s`);
}
const replied = await waitForReply(messageStore, agentId, printedIds, output, pollIntervalMs, replyTimeoutMs, conversationId, threadMessageIds);
if (!replied) console.error(`No reply within ${Math.ceil(replyTimeoutMs / 1000)}s`);
return 0;
}
const abortController = new AbortController();
const pendingReplies = new Map<string, PendingReply>();
let wakePoller: (() => void) | undefined;
const poller = (async () => {
while (!abortController.signal.aborted) {
const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (!isConversationReply(message, conversationId, threadMessageIds)) continue;
for (const message of await getChatReplies(messageStore, agentId, conversationId, threadMessageIds)) {
if (printedIds.has(message.id)) continue;
printedIds.add(message.id);
const pending = findPendingReply(pendingReplies, message);
/*
FNXC:CliChatReplyRouting 2026-07-20-12:00:
Polling can wake exactly at a pending deadline after a reply was
already persisted. Match replies created by that deadline before
expiring requests, otherwise the terminal falsely prints a timeout
immediately before the reply it has just retrieved.
*/
if (pending && Date.parse(message.createdAt) <= pending.deadlineAt) {
pendingReplies.delete(pending.outboundMessageId);
}
printMessage(output, message);
await messageStore.markAsRead(message.id);
}
await sleep(pollIntervalMs, abortController.signal);
expirePendingReplies(pendingReplies, output, Date.now());
const delay = nearestPendingSleep(pendingReplies, pollIntervalMs, Date.now());
let resolveWake: (() => void) | undefined;
const wake = new Promise<void>((resolve) => { resolveWake = resolve; });
wakePoller = resolveWake;
/*
FNXC:CliChatReplyRouting 2026-07-20-12:00:
Interactive chat owns independent pending deadlines. Wake a normal poll
when a new outbound is registered, then cap its sleep at the nearest
pending deadline; timing out one request only clears that entry and the
REPL continues polling for later messages.
*/
await sleep(delay, abortController.signal, wake);
wakePoller = undefined;
}
})().catch(() => undefined);
@@ -306,13 +376,12 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
if (!line) continue;
if (line === "/exit" || line === "/quit") break;
if (line === "/help") {
output.write(`Commands: /help, /history, /clear, /exit, /quit. Mailbox delivery to the agent inbox; conversation-id: ${conversationId}\n`);
output.write(`Commands: /help, /history, /clear, /exit, /quit\nMailbox delivery to the agent inbox; conversation-id: ${conversationId}\n`);
continue;
}
if (line === "/history") {
const history = collectConversationMessages(await messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
{ id: CLI_USER_ID, type: "user" }, { id: agentId, type: "agent" },
), conversationId, threadMessageIds).slice(-HISTORY_LIMIT);
for (const message of history) printedIds.add(message.id);
printConversationTail(output, history);
@@ -327,21 +396,24 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
continue;
}
const sentMessage = await messageStore.sendMessage({
const outbound = await messageStore.sendMessage({
fromId: CLI_USER_ID,
fromType: "user",
toId: agentId,
toType: "agent",
content: line,
type: "user-to-agent",
/*
FNXC:CliChatConversation 2026-07-20-12:00:
REPL sends share the same conversation identity as once-mode sends;
MessageStore remains the transport rather than masquerading as a room.
*/
metadata: { wakeRecipient: true, kind: "cli-chat", conversationId },
});
threadMessageIds.add(sentMessage.id);
threadMessageIds.add(outbound.id);
const sentAt = Date.now();
pendingReplies.set(outbound.id, {
outboundMessageId: outbound.id,
sentAt,
deadlineAt: sentAt + replyTimeoutMs,
preview: line.length > 80 ? `${line.slice(0, 80)}…` : line,
});
wakePoller?.();
output.write(`you → ${agentId}: ${line}\n`);
}
@@ -355,21 +427,9 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
} finally {
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Chat owns three independently-failing resources. Always attempt AgentStore, message database, and borrowed project teardown; report all cleanup failures without discarding an earlier command failure. */
const cleanupFailures: unknown[] = [];
try {
agentStore.close();
} catch (error) {
cleanupFailures.push(error);
}
try {
await messageOwner?.db.close();
} catch (error) {
cleanupFailures.push(error);
}
try {
await ownedAgentStore.cleanup();
} catch (error) {
cleanupFailures.push(error);
}
try { agentStore.close(); } catch (error) { cleanupFailures.push(error); }
try { await messageOwner?.db.close(); } catch (error) { cleanupFailures.push(error); }
try { await ownedAgentStore.cleanup(); } catch (error) { cleanupFailures.push(error); }
if (cleanupFailures.length > 0) {
// eslint-disable-next-line no-unsafe-finally -- cleanup must aggregate with, rather than silently lose, the active command failure.
throw new AggregateError(
@@ -380,19 +440,12 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
}
}
async function readSingleMessage(
input: NodeJS.ReadableStream,
output: NodeJS.WritableStream,
nonInteractive?: boolean,
): Promise<string> {
async function readSingleMessage(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, nonInteractive?: boolean): Promise<string> {
if (nonInteractive) {
const chunks: Buffer[] = [];
for await (const chunk of input) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
for await (const chunk of input) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
return Buffer.concat(chunks).toString("utf8").trimEnd();
}
const rl = createInterface({ input, output });
try {
return await rl.question("");

View File

@@ -1,4 +1,4 @@
import { MessageStore } from "@fusion/core";
import { DASHBOARD_USER_ID, MessageStore } from "@fusion/core";
import type { ParticipantType } from "@fusion/core";
import { resolveAgentStoreBase } from "../project-context.js";
@@ -23,16 +23,22 @@ export async function createMessageStore(projectName?: string): Promise<{ store:
export const CLI_USER_ID = "cli";
/**
* List inbox messages.
* List inbox messages for the CLI or dashboard operator mailbox.
*
* FNXC:CliChatReplyRouting 2026-07-20-12:00:
* Fusion deliberately keeps CLI (`cli`) and dashboard (`dashboard`) user
* mailboxes distinct. Default to CLI mail for backwards compatibility, while
* allowing automation to inspect the dashboard mailbox where legacy replies
* may have landed.
*/
export async function runMessageInbox(projectName?: string): Promise<void> {
export async function runMessageInbox(projectName?: string, ownerId = CLI_USER_ID): Promise<void> {
const { store, db } = await createMessageStore(projectName);
try {
const mailbox = await store.getMailbox(CLI_USER_ID, "user");
const messages = await store.getInbox(CLI_USER_ID, "user", { limit: 20 });
const mailbox = await store.getMailbox(ownerId, "user");
const messages = await store.getInbox(ownerId, "user", { limit: 20 });
console.log();
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
console.log(` 📬 ${ownerId === DASHBOARD_USER_ID ? "Dashboard Inbox" : "Inbox"} (${mailbox.unreadCount} unread)`);
console.log();
if (messages.length === 0) {