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:
7
.changeset/fn-8425-cli-chat-conversation.md
Normal file
7
.changeset/fn-8425-cli-chat-conversation.md
Normal 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.
|
||||
@@ -26,14 +26,16 @@ Use `fn chat` to message an agent from your terminal.
|
||||
### Synopsis
|
||||
|
||||
```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
|
||||
|
||||
- `fn chat <agent-id>` 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 <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`, `metadata.kind="cli-chat"`, and a durable `metadata.conversationId`.
|
||||
- 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.
|
||||
- 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 <n>` override poll interval in milliseconds (default `1000`, or `FUSION_CHAT_POLL_MS`)
|
||||
- `--conversation-id <id>` override the default named mailbox conversation
|
||||
|
||||
### Examples
|
||||
|
||||
|
||||
@@ -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 <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
||||
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--conversation-id <id>]
|
||||
```
|
||||
|
||||
### 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).
|
||||
- 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:<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.
|
||||
- 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 <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
||||
| `--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 <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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
34
packages/cli/src/__tests__/bin-chat-args.test.ts
Normal file
34
packages/cli/src/__tests__/bin-chat-args.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 <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>]
|
||||
Interactive or one-shot chat with an agent
|
||||
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 backup --create Create a database backup immediately
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> 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 <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]";
|
||||
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);
|
||||
|
||||
162
packages/cli/src/commands/__tests__/chat.test.ts
Normal file
162
packages/cli/src/commands/__tests__/chat.test.ts
Normal 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() }));
|
||||
});
|
||||
});
|
||||
@@ -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<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:
|
||||
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> {
|
||||
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<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;
|
||||
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<string>();
|
||||
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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,12 @@ export interface MessageMetadata extends Record<string, unknown> {
|
||||
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;
|
||||
|
||||
@@ -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:"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user