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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 10:20:41 -07:00
parent 46a57b52f7
commit 9db0ffc1f9
10 changed files with 418 additions and 52 deletions

View File

@@ -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.

View File

@@ -26,14 +26,16 @@ Use `fn chat` to message an agent from your terminal.
### Synopsis ### Synopsis
```bash ```bash
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]
``` ```
### Behavior ### Behavior
- `fn chat <agent-id>` opens an interactive REPL. - `fn chat <agent-id>` 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`. - 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`.
- Agent replies are polled from your inbox and printed as they arrive. - The default conversation id is `cli-chat:cli:<agent-id>` and is stable for that CLI-user/agent pair in the selected project. Use `--conversation-id <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. - 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. - 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. - 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) - `--once` send one message and exit after first reply (or timeout)
- `--non-interactive` read full stdin to EOF as message body - `--non-interactive` read full stdin to EOF as message body
- `--poll-ms <n>` override poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`) - `--poll-ms <n>` override poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`)
- `--conversation-id <id>` override the default named mailbox conversation
### Examples ### Examples

View File

@@ -1071,18 +1071,20 @@ fn message delete MSG-123
## `fn chat` ## `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 ```bash
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]
``` ```
### Behavior ### Behavior
- `fn chat <agent-id>` starts an interactive REPL. - `fn chat <agent-id>` starts an interactive mailbox-conversation REPL.
- `fn chat <agent-id> <message…>` sends one message and waits for a reply (`--once` implied). - `fn chat <agent-id> <message…>` 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`. - The default conversation id is `cli-chat:cli:<agent-id>`, 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.
- Replies are polled from the CLI user inbox and printed as they arrive. - 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 ### Options
@@ -1091,6 +1093,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
| `--once` | Send one message and exit after first reply (or timeout). | | `--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). | | `--non-interactive` | Read full stdin to EOF as message body (useful for pipes/scripts). |
| `--poll-ms <n>` | Poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`). | | `--poll-ms <n>` | Poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`). |
| `--conversation-id <id>` | Override the default mailbox conversation id to name or share a thread. |
### Examples ### Examples
@@ -1105,7 +1108,7 @@ fn chat agent-abc123 "status update?"
printf "deploy report" | fn chat agent-abc123 --once --non-interactive 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. > See [Agents: Interactive CLI Chat](./agents.md#interactive-cli-chat) for agent-oriented details.

View File

@@ -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");
});
});

View File

@@ -145,7 +145,7 @@ async function loadCommandHandlers() {
const { runOrgExport } = await import("./commands/org-export.js"); const { runOrgExport } = await import("./commands/org-export.js");
const { runOrgImport } = await import("./commands/org-import.js"); const { runOrgImport } = await import("./commands/org-import.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.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 { 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 { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.js");
const { runPluginDev } = await import("./commands/plugin-dev.js"); const { runPluginDev } = await import("./commands/plugin-dev.js");
@@ -291,6 +291,7 @@ async function loadCommandHandlers() {
runExperimentFinalize, runExperimentFinalize,
runUpdate, runUpdate,
runChatInteractive, runChatInteractive,
parseChatCliArgs,
}; };
} }
@@ -452,8 +453,8 @@ PR:
fn message send <agent-id> <msg> Send a message to an agent fn message send <agent-id> <msg> Send a message to an agent
fn message read <id> Read a specific message fn message read <id> Read a specific message
fn message delete <id> Delete a message fn message delete <id> Delete a message
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]
Interactive or one-shot chat with an agent Named mailbox conversation; delivers to agent inbox (not a chat room)
fn backup --create Create a database backup immediately fn backup --create Create a database backup immediately
fn backup --list List all database backups fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file fn backup --restore <file> Restore database from a backup file
@@ -816,6 +817,7 @@ async function main() {
runExperimentFinalize, runExperimentFinalize,
runUpdate, runUpdate,
runChatInteractive, runChatInteractive,
parseChatCliArgs,
} = await loadCommandHandlers(); } = await loadCommandHandlers();
try { try {
@@ -2105,38 +2107,19 @@ async function main() {
} }
case "chat": { case "chat": {
const usage = "Usage: fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]"; const parsed = parseChatCliArgs(args.slice(1));
const agentId = args[1]; if ("error" in parsed) {
if (!agentId) { console.error(parsed.error);
console.error(usage);
process.exit(1); process.exit(1);
} }
const pollIdx = args.indexOf("--poll-ms"); const input = parsed.contentArg ? Readable.from(parsed.contentArg) : process.stdin;
const pollMs = pollIdx !== -1 && pollIdx + 1 < args.length const code = await runChatInteractive(parsed.agentId, {
? 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, {
project: projectName, project: projectName,
once, once: parsed.once,
nonInteractive, nonInteractive: parsed.nonInteractive,
pollIntervalMs: pollMs, pollIntervalMs: parsed.pollIntervalMs,
conversationId: parsed.conversationId,
input, input,
}); });
process.exit(code); process.exit(code);

View File

@@ -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() }));
});
});

View File

@@ -8,8 +8,18 @@ const MAX_MESSAGE_LENGTH = 8192;
const DEFAULT_POLL_MS = 1000; const DEFAULT_POLL_MS = 1000;
const HISTORY_LIMIT = 20; 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 { export interface ChatInteractiveOptions {
project?: string; project?: string;
conversationId?: string;
pollIntervalMs?: number; pollIntervalMs?: number;
replyTimeoutMs?: number; replyTimeoutMs?: number;
once?: boolean; once?: boolean;
@@ -18,6 +28,52 @@ export interface ChatInteractiveOptions {
output?: NodeJS.WritableStream; output?: NodeJS.WritableStream;
} }
export type ChatCliArgs = Pick<ChatInteractiveOptions, "conversationId" | "pollIntervalMs" | "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 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: FNXC:PostgresCutover 2026-07-05-12:00:
Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the 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<string>()): Message[] {
const includedIds = new Set<string>();
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<string>): boolean {
return collectConversationMessages([message], conversationId, threadMessageIds).length > 0;
}
function sleep(ms: number, signal: AbortSignal): Promise<void> { function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms); const timer = setTimeout(resolve, ms);
@@ -95,12 +182,15 @@ async function waitForReply(
output: NodeJS.WritableStream, output: NodeJS.WritableStream,
pollIntervalMs: number, pollIntervalMs: number,
timeoutMs: number, timeoutMs: number,
conversationId: string,
threadMessageIds: Set<string>,
): Promise<boolean> { ): Promise<boolean> {
const started = Date.now(); const started = Date.now();
while (Date.now() - started < timeoutMs) { while (Date.now() - started < timeoutMs) {
const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 }); const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) { for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue; if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (!isConversationReply(message, conversationId, threadMessageIds)) continue;
if (printedIds.has(message.id)) continue; if (printedIds.has(message.id)) continue;
printedIds.add(message.id); printedIds.add(message.id);
printMessage(output, message); printMessage(output, message);
@@ -116,6 +206,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
const output = options.output ?? process.stdout; const output = options.output ?? process.stdout;
const input = options.input ?? process.stdin; const input = options.input ?? process.stdin;
const pollIntervalMs = parsePollMs(options); const pollIntervalMs = parsePollMs(options);
const conversationId = buildCliChatConversationId(agentId, options.conversationId);
const ownedAgentStore = await createAgentStore(options.project); const ownedAgentStore = await createAgentStore(options.project);
const agentStore = ownedAgentStore.store; const agentStore = ownedAgentStore.store;
@@ -136,10 +227,18 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
{ id: CLI_USER_ID, type: "user" }, { id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" }, { id: agentId, type: "agent" },
); );
const tail = conversation.slice(-HISTORY_LIMIT); const threadMessageIds = new Set<string>();
const tail = collectConversationMessages(conversation, conversationId, threadMessageIds).slice(-HISTORY_LIMIT);
for (const message of tail) printedIds.add(message.id); 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"); output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
printConversationTail(output, tail); printConversationTail(output, tail);
@@ -153,19 +252,25 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
return 0; return 0;
} }
await messageStore.sendMessage({ const sentMessage = await messageStore.sendMessage({
fromId: CLI_USER_ID, fromId: CLI_USER_ID,
fromType: "user", fromType: "user",
toId: agentId, toId: agentId,
toType: "agent", toType: "agent",
content, content,
type: "user-to-agent", 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`); output.write(`you → ${agentId}: ${content}\n`);
const timeoutMs = options.replyTimeoutMs ?? Math.max(pollIntervalMs * 10, 30_000); 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) { if (!replied) {
console.error(`No reply within ${Math.ceil(timeoutMs / 1000)}s`); 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 }); const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) { for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue; if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (!isConversationReply(message, conversationId, threadMessageIds)) continue;
if (printedIds.has(message.id)) continue; if (printedIds.has(message.id)) continue;
printedIds.add(message.id); printedIds.add(message.id);
printMessage(output, message); printMessage(output, message);
@@ -200,14 +306,14 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
if (!line) continue; if (!line) continue;
if (line === "/exit" || line === "/quit") break; if (line === "/exit" || line === "/quit") break;
if (line === "/help") { 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; continue;
} }
if (line === "/history") { if (line === "/history") {
const history = (await messageStore.getConversation( const history = collectConversationMessages(await messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" }, { id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" }, { id: agentId, type: "agent" },
)).slice(-HISTORY_LIMIT); ), conversationId, threadMessageIds).slice(-HISTORY_LIMIT);
for (const message of history) printedIds.add(message.id); for (const message of history) printedIds.add(message.id);
printConversationTail(output, history); printConversationTail(output, history);
continue; continue;
@@ -221,15 +327,21 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
continue; continue;
} }
await messageStore.sendMessage({ const sentMessage = await messageStore.sendMessage({
fromId: CLI_USER_ID, fromId: CLI_USER_ID,
fromType: "user", fromType: "user",
toId: agentId, toId: agentId,
toType: "agent", toType: "agent",
content: line, content: line,
type: "user-to-agent", 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`); output.write(`you → ${agentId}: ${line}\n`);
} }

View File

@@ -89,6 +89,12 @@ export interface MessageMetadata extends Record<string, unknown> {
sessionId?: string; sessionId?: string;
/** Planning question that produced a planning-clarification message. */ /** Planning question that produced a planning-clarification message. */
questionId?: string; 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; proposedTask?: ProposedTaskMetadata;
proposalStatus?: "pending" | "creating" | "created" | "dismissed"; proposalStatus?: "pending" | "creating" | "created" | "dismissed";
createdTaskId?: string; createdTaskId?: string;

View File

@@ -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<typeof message & { metadata?: Record<string, unknown> }>) {
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:"),
});
});
});

View File

@@ -5238,7 +5238,15 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
const lines = messageEntries.map(({ message, replyContext }) => { const lines = messageEntries.map(({ message, replyContext }) => {
const timestamp = new Date(message.createdAt).toLocaleString(); const timestamp = new Date(message.createdAt).toLocaleString();
const readStatus = message.read ? "[read] " : "[unread] "; 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) { if (!replyContext) {
return baseLine; return baseLine;
} }