@agentplate/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Tests for the high-level mail client. Uses a REAL temp project root and a REAL
|
|
2
|
+
// SQLite mail database (no mocks) per the project's testing philosophy: the only
|
|
3
|
+
// thing we exercise is observable client behavior, so the underlying store runs for
|
|
4
|
+
// real against a throwaway file.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { createMailClient, resolveRecipients } from "./client.ts";
|
|
12
|
+
|
|
13
|
+
let root: string;
|
|
14
|
+
let client: ReturnType<typeof createMailClient>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// A fresh temp project root with the .agentplate dir the client expects.
|
|
18
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-mail-"));
|
|
19
|
+
mkdirSync(join(root, ".agentplate"), { recursive: true });
|
|
20
|
+
client = createMailClient(root);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
client.close();
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("createMailClient", () => {
|
|
29
|
+
test("rejects an empty root", () => {
|
|
30
|
+
expect(() => createMailClient("")).toThrow();
|
|
31
|
+
expect(() => createMailClient(" ")).toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("send then check returns the message as unread", () => {
|
|
35
|
+
const sent = client.send({
|
|
36
|
+
from: "orchestrator",
|
|
37
|
+
to: "builder-1",
|
|
38
|
+
subject: "start",
|
|
39
|
+
body: "begin task",
|
|
40
|
+
type: "dispatch",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(sent.id).toBeTruthy();
|
|
44
|
+
|
|
45
|
+
const inbox = client.check("builder-1");
|
|
46
|
+
expect(inbox.length).toBe(1);
|
|
47
|
+
expect(inbox[0]?.id).toBe(sent.id);
|
|
48
|
+
|
|
49
|
+
const unread = client.check("builder-1", { unreadOnly: true });
|
|
50
|
+
expect(unread.length).toBe(1);
|
|
51
|
+
expect(unread[0]?.read).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("check only returns the addressed agent's mail", () => {
|
|
55
|
+
client.send({ from: "a", to: "builder-1", subject: "s1", body: "b1", type: "status" });
|
|
56
|
+
client.send({ from: "a", to: "builder-2", subject: "s2", body: "b2", type: "status" });
|
|
57
|
+
|
|
58
|
+
expect(client.check("builder-1").length).toBe(1);
|
|
59
|
+
expect(client.check("builder-2").length).toBe(1);
|
|
60
|
+
expect(client.check("nobody").length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("checkInject formats unread mail and marks it read", () => {
|
|
64
|
+
client.send({
|
|
65
|
+
from: "scout-1",
|
|
66
|
+
to: "lead",
|
|
67
|
+
subject: "found it",
|
|
68
|
+
body: "the bug is in foo.ts",
|
|
69
|
+
type: "result",
|
|
70
|
+
});
|
|
71
|
+
client.send({
|
|
72
|
+
from: "scout-2",
|
|
73
|
+
to: "lead",
|
|
74
|
+
subject: "second",
|
|
75
|
+
body: "also check bar.ts",
|
|
76
|
+
type: "status",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const block = client.checkInject("lead");
|
|
80
|
+
|
|
81
|
+
// Header reflects the count.
|
|
82
|
+
expect(block).toContain("You have 2 new message(s):");
|
|
83
|
+
// Per-message metadata is present.
|
|
84
|
+
expect(block).toContain("From: scout-1");
|
|
85
|
+
expect(block).toContain("Subject: found it");
|
|
86
|
+
expect(block).toContain("Type: result");
|
|
87
|
+
// Bodies are included.
|
|
88
|
+
expect(block).toContain("the bug is in foo.ts");
|
|
89
|
+
expect(block).toContain("also check bar.ts");
|
|
90
|
+
|
|
91
|
+
// Side effect: the injected mail is now read, so a second inject is empty...
|
|
92
|
+
expect(client.checkInject("lead")).toBe("");
|
|
93
|
+
// ...and an unread-only check confirms nothing remains unread.
|
|
94
|
+
expect(client.check("lead", { unreadOnly: true }).length).toBe(0);
|
|
95
|
+
// The messages still exist (read, not deleted).
|
|
96
|
+
expect(client.check("lead").length).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("checkInject returns empty string when there is no unread mail", () => {
|
|
100
|
+
expect(client.checkInject("ghost")).toBe("");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("reply keeps the conversation in the same thread", () => {
|
|
104
|
+
const original = client.send({
|
|
105
|
+
from: "builder-1",
|
|
106
|
+
to: "lead",
|
|
107
|
+
subject: "question",
|
|
108
|
+
body: "which branch?",
|
|
109
|
+
type: "question",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The store's reply takes the replying agent as the third argument and routes
|
|
113
|
+
// the reply back to the original sender.
|
|
114
|
+
const replied = client.reply(original.id, "use main", "lead");
|
|
115
|
+
|
|
116
|
+
// The reply is a real, distinct message.
|
|
117
|
+
expect(replied.id).toBeTruthy();
|
|
118
|
+
expect(replied.id).not.toBe(original.id);
|
|
119
|
+
// It is addressed back to the original sender.
|
|
120
|
+
expect(replied.to).toBe("builder-1");
|
|
121
|
+
expect(replied.from).toBe("lead");
|
|
122
|
+
|
|
123
|
+
// It belongs to the same thread as the original. The original started a new
|
|
124
|
+
// thread (threadId === null), so the store adopts the original's id as the
|
|
125
|
+
// shared thread root.
|
|
126
|
+
const expectedThread = original.threadId ?? original.id;
|
|
127
|
+
expect(replied.threadId).toBe(expectedThread);
|
|
128
|
+
|
|
129
|
+
// The original sender now has the reply waiting (unread).
|
|
130
|
+
const builderInbox = client.check("builder-1", { unreadOnly: true });
|
|
131
|
+
expect(builderInbox.some((m) => m.id === replied.id)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("markRead marks a single message read", () => {
|
|
135
|
+
const sent = client.send({
|
|
136
|
+
from: "a",
|
|
137
|
+
to: "builder-9",
|
|
138
|
+
subject: "s",
|
|
139
|
+
body: "b",
|
|
140
|
+
type: "status",
|
|
141
|
+
});
|
|
142
|
+
expect(client.check("builder-9", { unreadOnly: true }).length).toBe(1);
|
|
143
|
+
|
|
144
|
+
client.markRead(sent.id);
|
|
145
|
+
expect(client.check("builder-9", { unreadOnly: true }).length).toBe(0);
|
|
146
|
+
// Still present, just read.
|
|
147
|
+
expect(client.check("builder-9").length).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("list is a passthrough to the store", () => {
|
|
151
|
+
client.send({ from: "a", to: "x", subject: "s", body: "b", type: "status" });
|
|
152
|
+
client.send({ from: "a", to: "y", subject: "s", body: "b", type: "status" });
|
|
153
|
+
expect(client.list().length).toBe(2);
|
|
154
|
+
expect(client.list({ to: "x" }).length).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("purge removes messages and reports the count", () => {
|
|
158
|
+
client.send({ from: "a", to: "x", subject: "s", body: "b", type: "status" });
|
|
159
|
+
client.send({ from: "a", to: "y", subject: "s", body: "b", type: "status" });
|
|
160
|
+
|
|
161
|
+
const removed = client.purge({ all: true });
|
|
162
|
+
expect(removed).toBe(2);
|
|
163
|
+
expect(client.list().length).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("resolveRecipients", () => {
|
|
168
|
+
test("expands @all to every known agent", () => {
|
|
169
|
+
const agents = ["builder-1", "scout-1", "lead"];
|
|
170
|
+
expect(client.resolveRecipients("@all", agents)).toEqual(agents);
|
|
171
|
+
// Pure helper behaves identically.
|
|
172
|
+
expect(resolveRecipients("@all", agents)).toEqual(agents);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("@all de-duplicates and drops blanks", () => {
|
|
176
|
+
expect(resolveRecipients("@all", ["a", "a", "", " ", "b"])).toEqual(["a", "b"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("@all over an empty roster yields no recipients", () => {
|
|
180
|
+
expect(resolveRecipients("@all", [])).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a concrete address routes to itself", () => {
|
|
184
|
+
expect(resolveRecipients("builder-1", ["builder-1", "scout-1"])).toEqual(["builder-1"]);
|
|
185
|
+
// Unknown-but-concrete addresses still route to themselves (roster is advisory).
|
|
186
|
+
expect(resolveRecipients("ghost", ["builder-1"])).toEqual(["ghost"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("trims surrounding whitespace on the address", () => {
|
|
190
|
+
expect(resolveRecipients(" builder-1 ", [])).toEqual(["builder-1"]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// High-level mail client. Wraps the low-level SQLite mail store (createMailStore)
|
|
2
|
+
// with the operations the orchestrator and agents actually use: sending, checking
|
|
3
|
+
// an inbox, formatting unread mail for prompt injection, threading replies, and
|
|
4
|
+
// expanding broadcast recipients like "@all".
|
|
5
|
+
//
|
|
6
|
+
// WHY a separate layer over the store: the store is intentionally a thin,
|
|
7
|
+
// schema-faithful persistence API (rows in/rows out). The ergonomics callers want
|
|
8
|
+
// -- "give me an injectable markdown block of my unread mail and mark it read", or
|
|
9
|
+
// "expand @all into the live agent list" -- are policy, not storage, so they live
|
|
10
|
+
// here. This keeps the store reusable and unopinionated.
|
|
11
|
+
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { AGENTPLATE_DIR } from "../config.ts";
|
|
15
|
+
import { ValidationError } from "../errors.ts";
|
|
16
|
+
import type { MailMessage, NewMail } from "../types.ts";
|
|
17
|
+
import { createMailStore, type MailListFilter, type MailPurgeOptions } from "./store.ts";
|
|
18
|
+
|
|
19
|
+
// Broadcast sentinel: a recipient address that fans out to every known agent.
|
|
20
|
+
// Mirrors the agentplate mail convention. Kept as a single constant so the
|
|
21
|
+
// expansion rule and any future group sentinels have one source of truth.
|
|
22
|
+
const BROADCAST_ALL = "@all";
|
|
23
|
+
|
|
24
|
+
/** Optional filters when checking an inbox. */
|
|
25
|
+
export interface CheckOptions {
|
|
26
|
+
/** Return only unread messages (default: all). */
|
|
27
|
+
unreadOnly?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The high-level mail client surface returned by {@link createMailClient}. */
|
|
31
|
+
export interface MailClient {
|
|
32
|
+
/** Persist a new message. Delegates to the store. */
|
|
33
|
+
send(mail: NewMail): MailMessage;
|
|
34
|
+
/** Messages addressed to `agent`, newest first (optionally unread-only). */
|
|
35
|
+
check(agent: string, opts?: CheckOptions): MailMessage[];
|
|
36
|
+
/**
|
|
37
|
+
* Format an agent's UNREAD messages as a compact markdown block suitable for
|
|
38
|
+
* injecting into the agent's prompt, THEN mark those messages read. Returns ""
|
|
39
|
+
* when there is nothing unread. The mark-as-read side effect is intentional:
|
|
40
|
+
* once mail is in the prompt it is considered delivered, so a later inject for
|
|
41
|
+
* the same agent won't re-surface it.
|
|
42
|
+
*/
|
|
43
|
+
checkInject(agent: string): string;
|
|
44
|
+
/** List messages with optional filters (passthrough to the store). */
|
|
45
|
+
list(filter?: MailListFilter): MailMessage[];
|
|
46
|
+
/**
|
|
47
|
+
* Reply to a message in the same thread. `from` is the replying agent; the
|
|
48
|
+
* store routes the reply back to the original sender. Passthrough to the store.
|
|
49
|
+
*/
|
|
50
|
+
reply(id: string, body: string, from: string): MailMessage;
|
|
51
|
+
/** Mark a single message read (passthrough). */
|
|
52
|
+
markRead(id: string): void;
|
|
53
|
+
/** Delete messages per the given criteria; returns how many were removed. */
|
|
54
|
+
purge(opts?: MailPurgeOptions): number;
|
|
55
|
+
/**
|
|
56
|
+
* Expand a recipient address against the live agent roster. "@all" becomes the
|
|
57
|
+
* full `knownAgents` list; any other address is returned as a single recipient.
|
|
58
|
+
*/
|
|
59
|
+
resolveRecipients(to: string, knownAgents: string[]): string[];
|
|
60
|
+
/** Close the underlying database handle. */
|
|
61
|
+
close(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a mail client rooted at a project directory. Opens (and owns) the mail
|
|
66
|
+
* database at `<root>/.agentplate/mail.db`.
|
|
67
|
+
*
|
|
68
|
+
* @param root Absolute path to the project root containing `.agentplate/`.
|
|
69
|
+
*/
|
|
70
|
+
export function createMailClient(root: string): MailClient {
|
|
71
|
+
if (!root || root.trim().length === 0) {
|
|
72
|
+
throw new ValidationError("createMailClient requires a non-empty project root");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dbPath = join(root, AGENTPLATE_DIR, "mail.db");
|
|
76
|
+
const store = createMailStore(dbPath);
|
|
77
|
+
|
|
78
|
+
function check(agent: string, opts?: CheckOptions): MailMessage[] {
|
|
79
|
+
const recipient = normalizeAgent(agent);
|
|
80
|
+
// Let the store filter on both recipient and (optionally) unread so we lean on
|
|
81
|
+
// its indexed query rather than re-implementing the predicate. The store treats
|
|
82
|
+
// an absent `unread` filter as "all", which matches our default.
|
|
83
|
+
const filter: MailListFilter = { to: recipient };
|
|
84
|
+
if (opts?.unreadOnly) {
|
|
85
|
+
filter.unread = true;
|
|
86
|
+
}
|
|
87
|
+
return store.list(filter);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkInject(agent: string): string {
|
|
91
|
+
const unread = check(agent, { unreadOnly: true });
|
|
92
|
+
if (unread.length === 0) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const block = formatInjection(unread);
|
|
97
|
+
|
|
98
|
+
// Mark each injected message read AFTER formatting, so a failure mid-format
|
|
99
|
+
// does not silently consume mail the agent never saw.
|
|
100
|
+
for (const message of unread) {
|
|
101
|
+
store.markRead(message.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return block;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
send(mail: NewMail): MailMessage {
|
|
109
|
+
return store.send(mail);
|
|
110
|
+
},
|
|
111
|
+
check,
|
|
112
|
+
checkInject,
|
|
113
|
+
list(filter?: MailListFilter): MailMessage[] {
|
|
114
|
+
return store.list(filter);
|
|
115
|
+
},
|
|
116
|
+
reply(id: string, body: string, from: string): MailMessage {
|
|
117
|
+
return store.reply(id, body, from);
|
|
118
|
+
},
|
|
119
|
+
markRead(id: string): void {
|
|
120
|
+
store.markRead(id);
|
|
121
|
+
},
|
|
122
|
+
purge(opts?: MailPurgeOptions): number {
|
|
123
|
+
return store.purge(opts);
|
|
124
|
+
},
|
|
125
|
+
resolveRecipients(to: string, knownAgents: string[]): string[] {
|
|
126
|
+
return resolveRecipients(to, knownAgents);
|
|
127
|
+
},
|
|
128
|
+
close(): void {
|
|
129
|
+
store.close();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Expand a recipient address. "@all" fans out to every known agent (de-duplicated,
|
|
136
|
+
* blanks dropped); any other address routes to itself. Exported as a pure helper so
|
|
137
|
+
* callers (and tests) can use the expansion rule without opening a database.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveRecipients(to: string, knownAgents: string[]): string[] {
|
|
140
|
+
const target = normalizeAgent(to);
|
|
141
|
+
if (target === BROADCAST_ALL) {
|
|
142
|
+
// Dedupe while preserving roster order; skip blank entries defensively.
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const expanded: string[] = [];
|
|
145
|
+
for (const agent of knownAgents) {
|
|
146
|
+
const name = agent.trim();
|
|
147
|
+
if (name.length === 0 || seen.has(name)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
seen.add(name);
|
|
151
|
+
expanded.push(name);
|
|
152
|
+
}
|
|
153
|
+
return expanded;
|
|
154
|
+
}
|
|
155
|
+
return [target];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Trim and validate an agent/recipient identifier. */
|
|
159
|
+
function normalizeAgent(agent: string): string {
|
|
160
|
+
const name = agent?.trim();
|
|
161
|
+
if (!name) {
|
|
162
|
+
throw new ValidationError("mail recipient must be a non-empty string");
|
|
163
|
+
}
|
|
164
|
+
return name;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render unread messages as a compact markdown block for prompt injection.
|
|
169
|
+
* Shape (per the agent mail convention):
|
|
170
|
+
*
|
|
171
|
+
* You have 2 new message(s):
|
|
172
|
+
*
|
|
173
|
+
* 1. From: alice | Subject: build done | Type: status
|
|
174
|
+
* <body>
|
|
175
|
+
*
|
|
176
|
+
* 2. From: bob | ...
|
|
177
|
+
*/
|
|
178
|
+
function formatInjection(messages: MailMessage[]): string {
|
|
179
|
+
const header = `You have ${messages.length} new message(s):`;
|
|
180
|
+
const entries = messages.map((m, index) => {
|
|
181
|
+
const meta = `${index + 1}. From: ${m.from} | Subject: ${m.subject} | Type: ${m.type}`;
|
|
182
|
+
// Indent the body two spaces so it reads as part of the numbered item; a blank
|
|
183
|
+
// body (allowed by the schema) just omits the body line.
|
|
184
|
+
const indentedBody = m.body.length > 0 ? `\n ${m.body.split("\n").join("\n ")}` : "";
|
|
185
|
+
return `${meta}${indentedBody}`;
|
|
186
|
+
});
|
|
187
|
+
return `${header}\n\n${entries.join("\n\n")}`;
|
|
188
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the low-level SQLite mail store.
|
|
3
|
+
*
|
|
4
|
+
* Uses a real temp-file SQLite database (not a mock and not `:memory:`) so the
|
|
5
|
+
* tests exercise the same WAL-mode file path agents use in production. Each test
|
|
6
|
+
* gets a fresh file via beforeEach/afterEach.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { openDatabase } from "../db/sqlite.ts";
|
|
14
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
15
|
+
import type { NewMail } from "../types.ts";
|
|
16
|
+
import { createMailStore, type MailStore } from "./store.ts";
|
|
17
|
+
|
|
18
|
+
/** Minimal valid NewMail with overridable fields. */
|
|
19
|
+
function newMail(overrides: Partial<NewMail> = {}): NewMail {
|
|
20
|
+
return {
|
|
21
|
+
from: "alice",
|
|
22
|
+
to: "bob",
|
|
23
|
+
subject: "hello",
|
|
24
|
+
body: "world",
|
|
25
|
+
type: "status",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("mail store", () => {
|
|
31
|
+
let tmpRoot: string;
|
|
32
|
+
let dbPath: string;
|
|
33
|
+
let store: MailStore;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "agentplate-mail-"));
|
|
37
|
+
dbPath = join(tmpRoot, "mail.db");
|
|
38
|
+
store = createMailStore(dbPath);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
store.close();
|
|
43
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("send", () => {
|
|
47
|
+
test("assigns id, createdAt, read=false and default priority/threadId", () => {
|
|
48
|
+
const msg = store.send(newMail());
|
|
49
|
+
expect(msg.id).toBeTruthy();
|
|
50
|
+
expect(msg.read).toBe(false);
|
|
51
|
+
expect(msg.priority).toBe("normal");
|
|
52
|
+
expect(msg.threadId).toBeNull();
|
|
53
|
+
expect(msg.payload).toBeNull();
|
|
54
|
+
// createdAt is an ISO-8601 string the Date constructor round-trips.
|
|
55
|
+
expect(new Date(msg.createdAt).toISOString()).toBe(msg.createdAt);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("assigns unique ids across sends", () => {
|
|
59
|
+
const a = store.send(newMail());
|
|
60
|
+
const b = store.send(newMail());
|
|
61
|
+
expect(a.id).not.toBe(b.id);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("honors explicit priority, threadId and payload", () => {
|
|
65
|
+
const msg = store.send(
|
|
66
|
+
newMail({ priority: "urgent", threadId: "thread-1", payload: '{"k":1}' }),
|
|
67
|
+
);
|
|
68
|
+
expect(msg.priority).toBe("urgent");
|
|
69
|
+
expect(msg.threadId).toBe("thread-1");
|
|
70
|
+
expect(msg.payload).toBe('{"k":1}');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("persists across a reopen of the same db file", () => {
|
|
74
|
+
const sent = store.send(newMail({ subject: "persisted" }));
|
|
75
|
+
store.close();
|
|
76
|
+
const reopened = createMailStore(dbPath);
|
|
77
|
+
try {
|
|
78
|
+
const fetched = reopened.getById(sent.id);
|
|
79
|
+
expect(fetched?.subject).toBe("persisted");
|
|
80
|
+
} finally {
|
|
81
|
+
reopened.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rejects empty to/from addresses", () => {
|
|
86
|
+
expect(() => store.send(newMail({ to: "" }))).toThrow(ValidationError);
|
|
87
|
+
expect(() => store.send(newMail({ from: " " }))).toThrow(ValidationError);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("getInbox", () => {
|
|
92
|
+
test("returns only messages addressed to the agent, newest first", () => {
|
|
93
|
+
store.send(newMail({ to: "bob", subject: "first" }));
|
|
94
|
+
store.send(newMail({ to: "carol", subject: "other" }));
|
|
95
|
+
const second = store.send(newMail({ to: "bob", subject: "second" }));
|
|
96
|
+
|
|
97
|
+
const inbox = store.getInbox("bob");
|
|
98
|
+
expect(inbox).toHaveLength(2);
|
|
99
|
+
// Newest first: the last "bob" message leads.
|
|
100
|
+
expect(inbox[0]?.id).toBe(second.id);
|
|
101
|
+
expect(inbox.every((m) => m.to === "bob")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("unreadOnly filters out read messages", () => {
|
|
105
|
+
const a = store.send(newMail({ to: "bob" }));
|
|
106
|
+
store.send(newMail({ to: "bob" }));
|
|
107
|
+
store.markRead(a.id);
|
|
108
|
+
|
|
109
|
+
const unread = store.getInbox("bob", { unreadOnly: true });
|
|
110
|
+
expect(unread).toHaveLength(1);
|
|
111
|
+
expect(unread[0]?.read).toBe(false);
|
|
112
|
+
|
|
113
|
+
// Without the filter, both messages come back.
|
|
114
|
+
expect(store.getInbox("bob")).toHaveLength(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns an empty array for an unknown agent", () => {
|
|
118
|
+
expect(store.getInbox("nobody")).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("markRead / getById", () => {
|
|
123
|
+
test("markRead flips the read flag", () => {
|
|
124
|
+
const msg = store.send(newMail());
|
|
125
|
+
expect(store.getById(msg.id)?.read).toBe(false);
|
|
126
|
+
store.markRead(msg.id);
|
|
127
|
+
expect(store.getById(msg.id)?.read).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("markRead on a missing id is a no-op", () => {
|
|
131
|
+
expect(() => store.markRead("does-not-exist")).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("getById returns null for a missing id", () => {
|
|
135
|
+
expect(store.getById("missing")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("reply", () => {
|
|
140
|
+
test("starts a thread from a thread-less original (adopts original id)", () => {
|
|
141
|
+
const original = store.send(newMail({ from: "alice", to: "bob", subject: "ping" }));
|
|
142
|
+
const reply = store.reply(original.id, "pong", "bob");
|
|
143
|
+
|
|
144
|
+
expect(reply.threadId).toBe(original.id);
|
|
145
|
+
expect(reply.to).toBe("alice"); // routed back to the sender
|
|
146
|
+
expect(reply.from).toBe("bob");
|
|
147
|
+
expect(reply.type).toBe("result");
|
|
148
|
+
expect(reply.subject).toBe("ping"); // inherits the subject
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("propagates an existing threadId across replies", () => {
|
|
152
|
+
const original = store.send(newMail({ from: "alice", to: "bob", threadId: "t-42" }));
|
|
153
|
+
const reply = store.reply(original.id, "re", "bob");
|
|
154
|
+
expect(reply.threadId).toBe("t-42");
|
|
155
|
+
|
|
156
|
+
// A reply to the reply stays on the same thread.
|
|
157
|
+
const second = store.reply(reply.id, "re re", "alice");
|
|
158
|
+
expect(second.threadId).toBe("t-42");
|
|
159
|
+
expect(second.to).toBe("bob");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("throws NotFoundError when the original is missing", () => {
|
|
163
|
+
expect(() => store.reply("missing", "body", "bob")).toThrow(NotFoundError);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("list", () => {
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
store.send(newMail({ from: "alice", to: "bob", subject: "a" }));
|
|
170
|
+
store.send(newMail({ from: "carol", to: "bob", subject: "b" }));
|
|
171
|
+
store.send(newMail({ from: "alice", to: "dave", subject: "c" }));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("filters by from", () => {
|
|
175
|
+
const fromAlice = store.list({ from: "alice" });
|
|
176
|
+
expect(fromAlice).toHaveLength(2);
|
|
177
|
+
expect(fromAlice.every((m) => m.from === "alice")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("filters by to", () => {
|
|
181
|
+
const toBob = store.list({ to: "bob" });
|
|
182
|
+
expect(toBob).toHaveLength(2);
|
|
183
|
+
expect(toBob.every((m) => m.to === "bob")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("combines from + to filters", () => {
|
|
187
|
+
const combined = store.list({ from: "alice", to: "bob" });
|
|
188
|
+
expect(combined).toHaveLength(1);
|
|
189
|
+
expect(combined[0]?.subject).toBe("a");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("filters by unread", () => {
|
|
193
|
+
const all = store.list();
|
|
194
|
+
expect(all).toHaveLength(3);
|
|
195
|
+
const first = all[0];
|
|
196
|
+
expect(first).toBeDefined();
|
|
197
|
+
if (first) store.markRead(first.id);
|
|
198
|
+
|
|
199
|
+
expect(store.list({ unread: true })).toHaveLength(2);
|
|
200
|
+
expect(store.list({ unread: false })).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("applies a limit (newest first)", () => {
|
|
204
|
+
const limited = store.list({ limit: 2 });
|
|
205
|
+
expect(limited).toHaveLength(2);
|
|
206
|
+
// The most recently inserted message ("c") should lead.
|
|
207
|
+
expect(limited[0]?.subject).toBe("c");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("with no filter returns everything", () => {
|
|
211
|
+
expect(store.list()).toHaveLength(3);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("purge", () => {
|
|
216
|
+
test("all deletes every message and reports the count", () => {
|
|
217
|
+
store.send(newMail());
|
|
218
|
+
store.send(newMail());
|
|
219
|
+
expect(store.purge({ all: true })).toBe(2);
|
|
220
|
+
expect(store.list()).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("agent deletes messages the agent sent or received", () => {
|
|
224
|
+
store.send(newMail({ from: "alice", to: "bob" }));
|
|
225
|
+
store.send(newMail({ from: "carol", to: "alice" }));
|
|
226
|
+
store.send(newMail({ from: "carol", to: "dave" }));
|
|
227
|
+
|
|
228
|
+
const deleted = store.purge({ agent: "alice" });
|
|
229
|
+
expect(deleted).toBe(2);
|
|
230
|
+
|
|
231
|
+
const remaining = store.list();
|
|
232
|
+
expect(remaining).toHaveLength(1);
|
|
233
|
+
expect(remaining[0]?.from).toBe("carol");
|
|
234
|
+
expect(remaining[0]?.to).toBe("dave");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("olderThanDays deletes only sufficiently old messages", () => {
|
|
238
|
+
const fresh = store.send(newMail({ subject: "fresh" }));
|
|
239
|
+
const old = store.send(newMail({ subject: "old" }));
|
|
240
|
+
|
|
241
|
+
// Backdate the "old" message 30 days into the past by writing created_at
|
|
242
|
+
// directly. We use a real second connection to the same file (allowed —
|
|
243
|
+
// these are real-SQLite tests, no mocks) because the public API
|
|
244
|
+
// intentionally has no "set timestamp" surface.
|
|
245
|
+
const backdated = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
246
|
+
const raw = openDatabase(dbPath);
|
|
247
|
+
try {
|
|
248
|
+
raw.query("UPDATE messages SET created_at = $ts WHERE id = $id").run({
|
|
249
|
+
$ts: backdated,
|
|
250
|
+
$id: old.id,
|
|
251
|
+
});
|
|
252
|
+
} finally {
|
|
253
|
+
raw.close();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Purge anything older than 7 days: only the backdated message qualifies.
|
|
257
|
+
expect(store.purge({ olderThanDays: 7 })).toBe(1);
|
|
258
|
+
expect(store.getById(old.id)).toBeNull();
|
|
259
|
+
expect(store.getById(fresh.id)).not.toBeNull();
|
|
260
|
+
|
|
261
|
+
// A 10-year window now matches nothing (the only old row is gone).
|
|
262
|
+
expect(store.purge({ olderThanDays: 3650 })).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("with no options deletes nothing (all flag required to wipe)", () => {
|
|
266
|
+
store.send(newMail());
|
|
267
|
+
expect(store.purge()).toBe(0);
|
|
268
|
+
expect(store.purge({})).toBe(0);
|
|
269
|
+
expect(store.list()).toHaveLength(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("combines olderThanDays + agent (returns 0 when nothing matches)", () => {
|
|
273
|
+
store.send(newMail({ from: "alice", to: "bob", subject: "recent" }));
|
|
274
|
+
// Nothing is both old AND involving zoe, so count is 0 and the row stays.
|
|
275
|
+
expect(store.purge({ olderThanDays: 1, agent: "zoe" })).toBe(0);
|
|
276
|
+
expect(store.list()).toHaveLength(1);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|