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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user