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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
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 { 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);
|
||||||
|
|||||||
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 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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user