From 9db0ffc1f918bda20a9ab33c73834ccf6a2a896a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 10:20:41 -0700 Subject: [PATCH] FN-8425: route CLI chat through agent inbox Route CLI chat messages through durable, named agent mailbox conversations. - Add conversation IDs and parsing for CLI chat sessions. - Filter CLI chat history and replies by mailbox conversation identity. - Surface conversation IDs to agents and document the inbox-based transport. Files changed: .changeset/fn-8425-cli-chat-conversation.md | 7 + docs/agents.md | 11 +- docs/cli-reference.md | 15 +- packages/cli/src/__tests__/bin-chat-args.test.ts | 34 +++++ packages/cli/src/bin.ts | 45 ++---- packages/cli/src/commands/__tests__/chat.test.ts | 162 +++++++++++++++++++++ packages/cli/src/commands/chat.ts | 132 +++++++++++++++-- packages/core/src/types/messages.ts | 6 + .../__tests__/agent-tools-read-messages.test.ts | 48 ++++++ packages/engine/src/agent-tools.ts | 10 +- 10 files changed, 418 insertions(+), 52 deletions(-) Fusion-Task-Id: FN-8425 Fusion-Task-Lineage: 5091f49f-1f12-4ff7-8d21-48f008cf984e Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8425-cli-chat-conversation.md | 7 + docs/agents.md | 11 +- docs/cli-reference.md | 15 +- .../cli/src/__tests__/bin-chat-args.test.ts | 34 ++++ packages/cli/src/bin.ts | 45 ++--- .../cli/src/commands/__tests__/chat.test.ts | 162 ++++++++++++++++++ packages/cli/src/commands/chat.ts | 132 ++++++++++++-- packages/core/src/types/messages.ts | 6 + .../agent-tools-read-messages.test.ts | 48 ++++++ packages/engine/src/agent-tools.ts | 10 +- 10 files changed, 418 insertions(+), 52 deletions(-) create mode 100644 .changeset/fn-8425-cli-chat-conversation.md create mode 100644 packages/cli/src/__tests__/bin-chat-args.test.ts create mode 100644 packages/cli/src/commands/__tests__/chat.test.ts create mode 100644 packages/engine/src/__tests__/agent-tools-read-messages.test.ts diff --git a/.changeset/fn-8425-cli-chat-conversation.md b/.changeset/fn-8425-cli-chat-conversation.md new file mode 100644 index 0000000000..f011c7693d --- /dev/null +++ b/.changeset/fn-8425-cli-chat-conversation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make fn chat a named mailbox conversation with a stable conversation id. +category: fix +dev: Stamps MessageStore sends with kind=cli-chat + conversationId; honest help/docs; fn_read_messages surfaces conversation id. diff --git a/docs/agents.md b/docs/agents.md index 254debf43c..48316e4bc5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,14 +26,16 @@ Use `fn chat` to message an agent from your terminal. ### Synopsis ```bash -fn chat [message…] [--once] [--non-interactive] [--poll-ms ] +fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [--conversation-id ] ``` ### Behavior -- `fn chat ` opens an interactive REPL. -- Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. -- Agent replies are polled from your inbox and printed as they arrive. +- `fn chat ` opens an interactive **mailbox conversation** REPL. It delivers to the target agent's MessageStore inbox (`fn_read_messages`), not to a dashboard ChatStore/ChatView session or multi-agent room. +- Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`, `metadata.kind="cli-chat"`, and a durable `metadata.conversationId`. +- The default conversation id is `cli-chat:cli:` and is stable for that CLI-user/agent pair in the selected project. Use `--conversation-id ` to name or share a different mailbox thread; the CLI banner prints the active id. +- `fn message send` remains a distinct one-shot mailbox command and does not add CLI-chat conversation metadata. +- Agent replies are polled from your inbox and printed only when they carry that `conversationId` or use `replyTo.messageId` to reference a message already in the thread; reply streaming and dashboard operator-inbox readability are separately tracked by FN-8424 / issue #2363. - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled for the requesting project. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. - Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. @@ -62,6 +64,7 @@ For the user-facing gallery and notification UX, see [Artifacts View](./dashboar - `--once` send one message and exit after first reply (or timeout) - `--non-interactive` read full stdin to EOF as message body - `--poll-ms ` override poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`) +- `--conversation-id ` override the default named mailbox conversation ### Examples diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5955622497..0476aa67e5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1071,18 +1071,20 @@ fn message delete MSG-123 ## `fn chat` -Interactive CLI conversation loop with a specific agent. +Named mailbox conversation loop with a specific agent. It delivers through the agent's MessageStore inbox; it is **not** a dashboard ChatView/ChatStore session or a multi-agent room. ```bash -fn chat [message…] [--once] [--non-interactive] [--poll-ms ] +fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [--conversation-id ] ``` ### Behavior -- `fn chat ` starts an interactive REPL. +- `fn chat ` starts an interactive mailbox-conversation REPL. - `fn chat ` sends one message and waits for a reply (`--once` implied). -- Messages are sent as `user-to-agent` records from CLI user `cli` with `metadata.wakeRecipient=true`. -- Replies are polled from the CLI user inbox and printed as they arrive. +- The default conversation id is `cli-chat:cli:`, stable for the CLI user and target agent in the selected project. The session banner prints it so a later invocation resumes the same named thread. +- Messages are sent as `user-to-agent` records from CLI user `cli` with `metadata.wakeRecipient=true`, `metadata.kind="cli-chat"`, and `metadata.conversationId`. +- `fn message send` remains a one-shot mailbox command and does not add `cli-chat` conversation metadata. +- Replies are polled from the CLI user inbox and printed only when they carry the active `conversationId` or use `replyTo.messageId` to reference a message already in that thread. This mailbox path does not create a dashboard chat session or a multi-agent room. ### Options @@ -1091,6 +1093,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] | `--once` | Send one message and exit after first reply (or timeout). | | `--non-interactive` | Read full stdin to EOF as message body (useful for pipes/scripts). | | `--poll-ms ` | Poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`). | +| `--conversation-id ` | Override the default mailbox conversation id to name or share a thread. | ### Examples @@ -1105,7 +1108,7 @@ fn chat agent-abc123 "status update?" printf "deploy report" | fn chat agent-abc123 --once --non-interactive ``` -> Agent replies require a running engine for the same project (for example `fn dashboard` or `fn serve`). +> Agent replies require a running engine for the same project (for example `fn dashboard` or `fn serve`). Reply streaming and dashboard-operator inbox readability are tracked separately in FN-8424 / issue #2363. > > See [Agents: Interactive CLI Chat](./agents.md#interactive-cli-chat) for agent-oriented details. diff --git a/packages/cli/src/__tests__/bin-chat-args.test.ts b/packages/cli/src/__tests__/bin-chat-args.test.ts new file mode 100644 index 0000000000..11fd4af4d0 --- /dev/null +++ b/packages/cli/src/__tests__/bin-chat-args.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseChatCliArgs } from "../commands/chat.js"; + +describe("fn chat argument parsing", () => { + it("passes --conversation-id through from argv-shaped chat arguments", () => { + expect(parseChatCliArgs(["agent-001", "hello", "--conversation-id", "custom-thread"])).toMatchObject({ + agentId: "agent-001", + contentArg: "hello", + once: true, + nonInteractive: true, + conversationId: "custom-thread", + }); + }); + + it("rejects a missing --conversation-id value instead of treating it as message content", () => { + expect(parseChatCliArgs(["agent-001", "--conversation-id"])).toMatchObject({ + error: expect.stringContaining("Usage: fn chat"), + }); + expect(parseChatCliArgs(["agent-001", "--conversation-id", "--once"])).toMatchObject({ + error: expect.stringContaining("Usage: fn chat"), + }); + expect(parseChatCliArgs(["agent-001", "--conversation-id", "good", "--conversation-id"])).toMatchObject({ + error: expect.stringContaining("Usage: fn chat"), + }); + }); + + it("describes fn chat as agent inbox delivery in top-level help", () => { + const source = readFileSync(resolve(__dirname, "../bin.ts"), "utf8"); + + expect(source).toContain("Named mailbox conversation; delivers to agent inbox"); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index c8cefa2174..5bb649cc65 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -145,7 +145,7 @@ async function loadCommandHandlers() { const { runOrgExport } = await import("./commands/org-export.js"); const { runOrgImport } = await import("./commands/org-import.js"); const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js"); - const { runChatInteractive } = await import("./commands/chat.js"); + const { runChatInteractive, parseChatCliArgs } = await import("./commands/chat.js"); const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js"); const { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.js"); const { runPluginDev } = await import("./commands/plugin-dev.js"); @@ -291,6 +291,7 @@ async function loadCommandHandlers() { runExperimentFinalize, runUpdate, runChatInteractive, + parseChatCliArgs, }; } @@ -452,8 +453,8 @@ PR: fn message send Send a message to an agent fn message read Read a specific message fn message delete Delete a message - fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Interactive or one-shot chat with an agent + fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [--conversation-id ] + Named mailbox conversation; delivers to agent inbox (not a chat room) fn backup --create Create a database backup immediately fn backup --list List all database backups fn backup --restore Restore database from a backup file @@ -816,6 +817,7 @@ async function main() { runExperimentFinalize, runUpdate, runChatInteractive, + parseChatCliArgs, } = await loadCommandHandlers(); try { @@ -2105,38 +2107,19 @@ async function main() { } case "chat": { - const usage = "Usage: fn chat [message…] [--once] [--non-interactive] [--poll-ms ]"; - const agentId = args[1]; - if (!agentId) { - console.error(usage); + const parsed = parseChatCliArgs(args.slice(1)); + if ("error" in parsed) { + console.error(parsed.error); process.exit(1); } - const pollIdx = args.indexOf("--poll-ms"); - const pollMs = pollIdx !== -1 && pollIdx + 1 < args.length - ? Number.parseInt(args[pollIdx + 1] ?? "", 10) - : undefined; - - if (pollIdx !== -1 && (!Number.isFinite(pollMs) || (pollMs ?? 0) <= 0)) { - console.error(usage); - process.exit(1); - } - - const filteredArgs = args.slice(2).filter((arg, idx, arr) => { - if (arg === "--once" || arg === "--non-interactive" || arg === "--poll-ms") return false; - if (idx > 0 && arr[idx - 1] === "--poll-ms") return false; - return true; - }); - const contentArg = filteredArgs.join(" ").trim(); - const once = args.includes("--once") || contentArg.length > 0; - const nonInteractive = args.includes("--non-interactive") || contentArg.length > 0; - const input = contentArg ? Readable.from(contentArg) : process.stdin; - - const code = await runChatInteractive(agentId, { + const input = parsed.contentArg ? Readable.from(parsed.contentArg) : process.stdin; + const code = await runChatInteractive(parsed.agentId, { project: projectName, - once, - nonInteractive, - pollIntervalMs: pollMs, + once: parsed.once, + nonInteractive: parsed.nonInteractive, + pollIntervalMs: parsed.pollIntervalMs, + conversationId: parsed.conversationId, input, }); process.exit(code); diff --git a/packages/cli/src/commands/__tests__/chat.test.ts b/packages/cli/src/commands/__tests__/chat.test.ts new file mode 100644 index 0000000000..1dd1bcdc2e --- /dev/null +++ b/packages/cli/src/commands/__tests__/chat.test.ts @@ -0,0 +1,162 @@ +import { PassThrough, Readable } from "node:stream"; +import { 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(), +})); + +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("../../project-context.js", () => ({ + resolveAgentStoreBase: vi.fn().mockResolvedValue({ + rootDir: "/tmp/fusion-cli-chat-test", + asyncLayer: {}, + cleanup: mockCleanup, + }), +})); + +import { runChatInteractive } from "../chat.js"; +import { runMessageSend } from "../message.js"; + +function outputBuffer(): { output: PassThrough; read: () => string } { + const output = new PassThrough(); + const chunks: Buffer[] = []; + output.on("data", (chunk: Buffer) => chunks.push(chunk)); + return { output, read: () => Buffer.concat(chunks).toString("utf8") }; +} + +describe("runChatInteractive mailbox conversation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAgent.mockResolvedValue({ id: "agent-001" }); + mockGetConversation.mockResolvedValue([]); + mockGetInbox.mockResolvedValue([]); + mockSendMessage.mockResolvedValue({ id: "msg-001" }); + mockCleanup.mockResolvedValue(undefined); + }); + + 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, + }); + } + + 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" }, + ]); + }); + + 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(); + + await runChatInteractive("agent-001", { + 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, + pollIntervalMs: 1, + }); + + expect(buffer.read()).toContain("associated"); + expect(buffer.read()).not.toContain("unrelated"); + expect(mockMarkAsRead).toHaveBeenCalledWith("reply"); + expect(mockMarkAsRead).not.toHaveBeenCalledWith("unrelated"); + }); + + it("uses an explicit conversation id and names inbox delivery in the session banner", async () => { + const buffer = outputBuffer(); + await runChatInteractive("agent-001", { + once: true, + nonInteractive: true, + input: Readable.from("hello"), + output: buffer.output, + conversationId: "custom-thread", + replyTimeoutMs: 0, + }); + + expect(mockSendMessage).toHaveBeenCalledWith(expect.objectContaining({ + metadata: { wakeRecipient: true, kind: "cli-chat", conversationId: "custom-thread" }, + })); + 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() })); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 56038f47fe..9922e2ba6e 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -8,8 +8,18 @@ const MAX_MESSAGE_LENGTH = 8192; const DEFAULT_POLL_MS = 1000; 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. + */ +export function buildCliChatConversationId(agentId: string, override?: string): string { + return override ?? `cli-chat:${CLI_USER_ID}:${agentId}`; +} + export interface ChatInteractiveOptions { project?: string; + conversationId?: string; pollIntervalMs?: number; replyTimeoutMs?: number; once?: boolean; @@ -18,6 +28,52 @@ export interface ChatInteractiveOptions { output?: NodeJS.WritableStream; } +export type ChatCliArgs = Pick & { + 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 [message…] [--once] [--non-interactive] [--poll-ms ] [--conversation-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 }; + } + + 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 }; + } + 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, + once: args.includes("--once") || contentArg.length > 0, + nonInteractive: args.includes("--non-interactive") || contentArg.length > 0, + }; +} + /* FNXC:PostgresCutover 2026-07-05-12:00: Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the @@ -73,6 +129,37 @@ function printConversationTail(output: NodeJS.WritableStream, messages: 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. + */ +function collectConversationMessages(messages: Message[], conversationId: string, threadMessageIds = new Set()): Message[] { + const includedIds = new Set(); + let changed = true; + while (changed) { + changed = false; + 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); + if (!threadMessageIds.has(message.id) && !directMatch && !replyMatch) continue; + includedIds.add(message.id); + if (!threadMessageIds.has(message.id)) { + threadMessageIds.add(message.id); + changed = true; + } + } + } + return messages.filter((message) => includedIds.has(message.id)); +} + +function isConversationReply(message: Message, conversationId: string, threadMessageIds: Set): boolean { + return collectConversationMessages([message], conversationId, threadMessageIds).length > 0; +} + function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); @@ -95,12 +182,15 @@ async function waitForReply( output: NodeJS.WritableStream, pollIntervalMs: number, timeoutMs: number, + conversationId: string, + threadMessageIds: Set, ): Promise { 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; if (printedIds.has(message.id)) continue; printedIds.add(message.id); printMessage(output, message); @@ -116,6 +206,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti const output = options.output ?? process.stdout; const input = options.input ?? process.stdin; const pollIntervalMs = parsePollMs(options); + const conversationId = buildCliChatConversationId(agentId, options.conversationId); const ownedAgentStore = await createAgentStore(options.project); const agentStore = ownedAgentStore.store; @@ -136,10 +227,18 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti { id: CLI_USER_ID, type: "user" }, { id: agentId, type: "agent" }, ); - const tail = conversation.slice(-HISTORY_LIMIT); + const threadMessageIds = new Set(); + const tail = collectConversationMessages(conversation, conversationId, threadMessageIds).slice(-HISTORY_LIMIT); for (const message of tail) printedIds.add(message.id); - output.write(`Chat with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`); + /* + 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); @@ -153,19 +252,25 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti return 0; } - await messageStore.sendMessage({ + const sentMessage = await messageStore.sendMessage({ fromId: CLI_USER_ID, fromType: "user", toId: agentId, toType: "agent", content, type: "user-to-agent", - metadata: { wakeRecipient: true }, + /* + 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 }, }); output.write(`you → ${agentId}: ${content}\n`); const timeoutMs = options.replyTimeoutMs ?? Math.max(pollIntervalMs * 10, 30_000); - const replied = await waitForReply(messageStore, agentId, printedIds, output, pollIntervalMs, timeoutMs); + 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`); } @@ -178,6 +283,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti 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; if (printedIds.has(message.id)) continue; printedIds.add(message.id); printMessage(output, message); @@ -200,14 +306,14 @@ 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\n"); + output.write(`Commands: /help, /history, /clear, /exit, /quit. Mailbox delivery to the agent inbox; conversation-id: ${conversationId}\n`); continue; } if (line === "/history") { - const history = (await messageStore.getConversation( + const history = collectConversationMessages(await messageStore.getConversation( { id: CLI_USER_ID, type: "user" }, { id: agentId, type: "agent" }, - )).slice(-HISTORY_LIMIT); + ), conversationId, threadMessageIds).slice(-HISTORY_LIMIT); for (const message of history) printedIds.add(message.id); printConversationTail(output, history); continue; @@ -221,15 +327,21 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti continue; } - await messageStore.sendMessage({ + const sentMessage = await messageStore.sendMessage({ fromId: CLI_USER_ID, fromType: "user", toId: agentId, toType: "agent", content: line, type: "user-to-agent", - metadata: { wakeRecipient: true }, + /* + 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); output.write(`you → ${agentId}: ${line}\n`); } diff --git a/packages/core/src/types/messages.ts b/packages/core/src/types/messages.ts index c511caf147..bad6e204a8 100644 --- a/packages/core/src/types/messages.ts +++ b/packages/core/src/types/messages.ts @@ -89,6 +89,12 @@ export interface MessageMetadata extends Record { sessionId?: string; /** Planning question that produced a planning-clarification message. */ questionId?: string; + /** + * FNXC:CliChatConversation 2026-07-20-12:00: + * CLI-to-agent mailbox chat needs a durable thread identity because MessageStore + * inbox delivery is not a dashboard ChatView session or a multi-agent room. + */ + conversationId?: string; proposedTask?: ProposedTaskMetadata; proposalStatus?: "pending" | "creating" | "created" | "dismissed"; createdTaskId?: string; diff --git a/packages/engine/src/__tests__/agent-tools-read-messages.test.ts b/packages/engine/src/__tests__/agent-tools-read-messages.test.ts new file mode 100644 index 0000000000..343f1b6fe1 --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-read-messages.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { createReadMessagesTool } from "../agent-tools.js"; + +const message = { + id: "msg-cli-chat", + fromId: "cli", + fromType: "user" as const, + toId: "agent-001", + toType: "agent" as const, + content: "Hello from the CLI", + type: "user-to-agent" as const, + read: false, + createdAt: "2026-07-20T12:00:00.000Z", + updatedAt: "2026-07-20T12:00:00.000Z", +}; + +function createMessageStore(messages: Array }>) { + return { + getInbox: vi.fn().mockResolvedValue(messages), + getMessage: vi.fn(), + }; +} + +describe("fn_read_messages conversation identity", () => { + it("shows a mailbox conversation id when message metadata provides one", async () => { + const store = createMessageStore([{ ...message, metadata: { conversationId: "cli-chat:cli:agent-001" } }]); + const tool = createReadMessagesTool(store as any, "agent-001"); + + const result = await tool.execute("call-1", {}); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("[conversation: cli-chat:cli:agent-001]"), + }); + }); + + it("does not add a conversation label for ordinary mailbox messages", async () => { + const store = createMessageStore([message]); + const tool = createReadMessagesTool(store as any, "agent-001"); + + const result = await tool.execute("call-1", {}); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.not.stringContaining("[conversation:"), + }); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 73eb95b973..85c3471396 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -5238,7 +5238,15 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri const lines = messageEntries.map(({ message, replyContext }) => { const timestamp = new Date(message.createdAt).toLocaleString(); const readStatus = message.read ? "[read] " : "[unread] "; - const baseLine = `${readStatus}[id: ${message.id}] [from: ${message.fromType}:${message.fromId}] ${message.content} (${timestamp})`; + /* + FNXC:CliChatConversation 2026-07-20-12:00: + MessageStore is intentionally the durable-agent CLI transport. Surface + its conversation identity here so inbox rows reveal a named thread. + */ + const conversationId = typeof message.metadata?.conversationId === "string" && message.metadata.conversationId.trim() + ? ` [conversation: ${message.metadata.conversationId}]` + : ""; + const baseLine = `${readStatus}[id: ${message.id}] [from: ${message.fromType}:${message.fromId}]${conversationId} ${message.content} (${timestamp})`; if (!replyContext) { return baseLine; }