agent-inbox 0.0.1 → 0.1.1

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.
Files changed (126) hide show
  1. package/CLAUDE.md +113 -0
  2. package/README.md +195 -1
  3. package/dist/federation/address.d.ts +24 -0
  4. package/dist/federation/address.d.ts.map +1 -0
  5. package/dist/federation/address.js +54 -0
  6. package/dist/federation/address.js.map +1 -0
  7. package/dist/federation/connection-manager.d.ts +118 -0
  8. package/dist/federation/connection-manager.d.ts.map +1 -0
  9. package/dist/federation/connection-manager.js +369 -0
  10. package/dist/federation/connection-manager.js.map +1 -0
  11. package/dist/federation/delivery-queue.d.ts +66 -0
  12. package/dist/federation/delivery-queue.d.ts.map +1 -0
  13. package/dist/federation/delivery-queue.js +199 -0
  14. package/dist/federation/delivery-queue.js.map +1 -0
  15. package/dist/federation/index.d.ts +7 -0
  16. package/dist/federation/index.d.ts.map +1 -0
  17. package/dist/federation/index.js +6 -0
  18. package/dist/federation/index.js.map +1 -0
  19. package/dist/federation/routing-engine.d.ts +74 -0
  20. package/dist/federation/routing-engine.d.ts.map +1 -0
  21. package/dist/federation/routing-engine.js +158 -0
  22. package/dist/federation/routing-engine.js.map +1 -0
  23. package/dist/federation/trust.d.ts +39 -0
  24. package/dist/federation/trust.d.ts.map +1 -0
  25. package/dist/federation/trust.js +64 -0
  26. package/dist/federation/trust.js.map +1 -0
  27. package/dist/index.d.ts +60 -2
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +217 -18
  30. package/dist/index.js.map +1 -1
  31. package/dist/ipc/ipc-server.d.ts +20 -0
  32. package/dist/ipc/ipc-server.d.ts.map +1 -0
  33. package/dist/ipc/ipc-server.js +152 -0
  34. package/dist/ipc/ipc-server.js.map +1 -0
  35. package/dist/jsonrpc/mail-server.d.ts +45 -0
  36. package/dist/jsonrpc/mail-server.d.ts.map +1 -0
  37. package/dist/jsonrpc/mail-server.js +284 -0
  38. package/dist/jsonrpc/mail-server.js.map +1 -0
  39. package/dist/map/map-client.d.ts +91 -0
  40. package/dist/map/map-client.d.ts.map +1 -0
  41. package/dist/map/map-client.js +202 -0
  42. package/dist/map/map-client.js.map +1 -0
  43. package/dist/mcp/mcp-server.d.ts +23 -0
  44. package/dist/mcp/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp/mcp-server.js +226 -0
  46. package/dist/mcp/mcp-server.js.map +1 -0
  47. package/dist/push/notifier.d.ts +49 -0
  48. package/dist/push/notifier.d.ts.map +1 -0
  49. package/dist/push/notifier.js +150 -0
  50. package/dist/push/notifier.js.map +1 -0
  51. package/dist/registry/warm-registry.d.ts +63 -0
  52. package/dist/registry/warm-registry.d.ts.map +1 -0
  53. package/dist/registry/warm-registry.js +173 -0
  54. package/dist/registry/warm-registry.js.map +1 -0
  55. package/dist/router/message-router.d.ts +44 -0
  56. package/dist/router/message-router.d.ts.map +1 -0
  57. package/dist/router/message-router.js +137 -0
  58. package/dist/router/message-router.js.map +1 -0
  59. package/dist/storage/interface.d.ts +31 -0
  60. package/dist/storage/interface.d.ts.map +1 -0
  61. package/dist/storage/interface.js +2 -0
  62. package/dist/storage/interface.js.map +1 -0
  63. package/dist/storage/memory.d.ts +28 -0
  64. package/dist/storage/memory.d.ts.map +1 -0
  65. package/dist/storage/memory.js +118 -0
  66. package/dist/storage/memory.js.map +1 -0
  67. package/dist/storage/sqlite.d.ts +35 -0
  68. package/dist/storage/sqlite.d.ts.map +1 -0
  69. package/dist/storage/sqlite.js +445 -0
  70. package/dist/storage/sqlite.js.map +1 -0
  71. package/dist/traceability/traceability.d.ts +29 -0
  72. package/dist/traceability/traceability.d.ts.map +1 -0
  73. package/dist/traceability/traceability.js +150 -0
  74. package/dist/traceability/traceability.js.map +1 -0
  75. package/dist/types.d.ts +253 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +3 -0
  78. package/dist/types.js.map +1 -0
  79. package/docs/DESIGN.md +1156 -0
  80. package/docs/PLAN.md +545 -0
  81. package/hooks/inbox-hook.mjs +119 -0
  82. package/hooks/register-hook.mjs +69 -0
  83. package/package.json +33 -25
  84. package/rules/agent-inbox.md +78 -0
  85. package/src/federation/address.ts +61 -0
  86. package/src/federation/connection-manager.ts +458 -0
  87. package/src/federation/delivery-queue.ts +222 -0
  88. package/src/federation/index.ts +6 -0
  89. package/src/federation/routing-engine.ts +188 -0
  90. package/src/federation/trust.ts +71 -0
  91. package/src/index.ts +299 -0
  92. package/src/ipc/ipc-server.ts +180 -0
  93. package/src/jsonrpc/mail-server.ts +356 -0
  94. package/src/map/map-client.ts +260 -0
  95. package/src/mcp/mcp-server.ts +272 -0
  96. package/src/push/notifier.ts +192 -0
  97. package/src/registry/warm-registry.ts +210 -0
  98. package/src/router/message-router.ts +175 -0
  99. package/src/storage/interface.ts +48 -0
  100. package/src/storage/memory.ts +145 -0
  101. package/src/storage/sqlite.ts +645 -0
  102. package/src/traceability/traceability.ts +183 -0
  103. package/src/types.ts +287 -0
  104. package/test/federation/address.test.ts +101 -0
  105. package/test/federation/connection-manager.test.ts +546 -0
  106. package/test/federation/delivery-queue.test.ts +159 -0
  107. package/test/federation/integration.test.ts +823 -0
  108. package/test/federation/routing-engine.test.ts +117 -0
  109. package/test/federation/sdk-integration.test.ts +748 -0
  110. package/test/federation/trust.test.ts +89 -0
  111. package/test/ipc-jsonrpc.test.ts +113 -0
  112. package/test/ipc-server.test.ts +138 -0
  113. package/test/mail-server.test.ts +208 -0
  114. package/test/map-client.test.ts +408 -0
  115. package/test/message-router.test.ts +184 -0
  116. package/test/push-notifier.test.ts +139 -0
  117. package/test/registry/warm-registry.test.ts +171 -0
  118. package/test/sqlite-storage.test.ts +243 -0
  119. package/test/storage.test.ts +196 -0
  120. package/test/traceability.test.ts +123 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +10 -0
  123. package/vitest.config.ts +8 -0
  124. package/dist/index.d.mts +0 -2
  125. package/dist/index.mjs +0 -1
  126. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { EventEmitter } from "node:events";
6
+ import { InMemoryStorage } from "../src/storage/memory.js";
7
+ import { MessageRouter } from "../src/router/message-router.js";
8
+ import { PushNotifier, formatInboxMarkdown } from "../src/push/notifier.js";
9
+ import type { InboxFileEntry } from "../src/push/notifier.js";
10
+
11
+ describe("PushNotifier", () => {
12
+ let storage: InMemoryStorage;
13
+ let events: EventEmitter;
14
+ let router: MessageRouter;
15
+ let notifier: PushNotifier;
16
+ let tmpDir: string;
17
+
18
+ beforeEach(() => {
19
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-push-test-"));
20
+ storage = new InMemoryStorage();
21
+ events = new EventEmitter();
22
+ router = new MessageRouter(storage, events, "default");
23
+ notifier = new PushNotifier({ inboxDir: tmpDir }, storage, events);
24
+ });
25
+
26
+ afterEach(() => {
27
+ fs.rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+
30
+ it("should write to per-agent inbox file on message.created", async () => {
31
+ await router.routeMessage({
32
+ from: "alice",
33
+ to: "bob",
34
+ payload: "hello bob",
35
+ });
36
+
37
+ const filePath = notifier.agentInboxPath("bob");
38
+ expect(fs.existsSync(filePath)).toBe(true);
39
+
40
+ const raw = fs.readFileSync(filePath, "utf-8").trim();
41
+ const entry = JSON.parse(raw) as InboxFileEntry;
42
+ expect(entry.from).toBe("alice");
43
+ expect(entry.to).toBe("bob");
44
+ expect(entry.content).toEqual({ type: "text", text: "hello bob" });
45
+ });
46
+
47
+ it("should write to multiple recipients", async () => {
48
+ await router.routeMessage({
49
+ from: "alice",
50
+ to: ["bob", "charlie"],
51
+ payload: "team update",
52
+ });
53
+
54
+ expect(fs.existsSync(notifier.agentInboxPath("bob"))).toBe(true);
55
+ expect(fs.existsSync(notifier.agentInboxPath("charlie"))).toBe(true);
56
+ });
57
+
58
+ it("should readAndClear inbox file", async () => {
59
+ await router.routeMessage({
60
+ from: "alice",
61
+ to: "bob",
62
+ payload: "message 1",
63
+ });
64
+ await router.routeMessage({
65
+ from: "charlie",
66
+ to: "bob",
67
+ payload: "message 2",
68
+ });
69
+
70
+ const markdown = notifier.readAndClearAgentInbox("bob");
71
+ expect(markdown).toBeTruthy();
72
+ expect(markdown).toContain("2 new message(s)");
73
+ expect(markdown).toContain("From alice");
74
+ expect(markdown).toContain("From charlie");
75
+
76
+ // File should be cleared
77
+ const raw = fs.readFileSync(notifier.agentInboxPath("bob"), "utf-8");
78
+ expect(raw).toBe("");
79
+ });
80
+
81
+ it("should return null for empty inbox", () => {
82
+ const result = notifier.readAndClearAgentInbox("nonexistent");
83
+ expect(result).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe("formatInboxMarkdown", () => {
88
+ it("should format text messages as markdown", () => {
89
+ const entries: InboxFileEntry[] = [
90
+ {
91
+ messageId: "m1",
92
+ from: "alice",
93
+ to: "bob",
94
+ content: { type: "text", text: "Fix the auth bug" },
95
+ importance: "high",
96
+ timestamp: new Date().toISOString(),
97
+ },
98
+ ];
99
+
100
+ const md = formatInboxMarkdown(entries);
101
+ expect(md).toContain("## [Inbox] 1 new message(s)");
102
+ expect(md).toContain("From alice");
103
+ expect(md).toContain("[high]");
104
+ expect(md).toContain("Fix the auth bug");
105
+ });
106
+
107
+ it("should show thread tags", () => {
108
+ const entries: InboxFileEntry[] = [
109
+ {
110
+ messageId: "m1",
111
+ from: "alice",
112
+ to: "bob",
113
+ content: { type: "text", text: "update" },
114
+ threadTag: "sprint-1",
115
+ importance: "normal",
116
+ timestamp: new Date().toISOString(),
117
+ },
118
+ ];
119
+
120
+ const md = formatInboxMarkdown(entries);
121
+ expect(md).toContain("thread: sprint-1");
122
+ });
123
+
124
+ it("should handle non-text content", () => {
125
+ const entries: InboxFileEntry[] = [
126
+ {
127
+ messageId: "m1",
128
+ from: "alice",
129
+ to: "bob",
130
+ content: { type: "data", data: { task: "deploy" } },
131
+ importance: "normal",
132
+ timestamp: new Date().toISOString(),
133
+ },
134
+ ];
135
+
136
+ const md = formatInboxMarkdown(entries);
137
+ expect(md).toContain("`");
138
+ });
139
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { InMemoryStorage } from "../../src/storage/memory.js";
4
+ import { WarmRegistry } from "../../src/registry/warm-registry.js";
5
+ import type { Agent } from "../../src/types.js";
6
+
7
+ function makeAgent(id: string, scope = "default"): Agent {
8
+ const now = new Date().toISOString();
9
+ return {
10
+ agent_id: id,
11
+ display_name: id,
12
+ scope,
13
+ status: "active",
14
+ metadata: {},
15
+ registered_at: now,
16
+ last_active_at: now,
17
+ };
18
+ }
19
+
20
+ describe("WarmRegistry", () => {
21
+ let storage: InMemoryStorage;
22
+ let events: EventEmitter;
23
+ let registry: WarmRegistry;
24
+
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ storage = new InMemoryStorage();
28
+ events = new EventEmitter();
29
+ registry = new WarmRegistry(storage, events, {
30
+ gracePeriodMs: 5000,
31
+ retainExpiredMs: 10000,
32
+ requeueOnReconnect: true,
33
+ });
34
+ });
35
+
36
+ afterEach(() => {
37
+ registry.destroy();
38
+ vi.useRealTimers();
39
+ });
40
+
41
+ describe("register", () => {
42
+ it("should register a new agent", () => {
43
+ const ok = registry.register(makeAgent("agent-1"));
44
+ expect(ok).toBe(true);
45
+ expect(registry.getStatus("agent-1")).toBe("active");
46
+ expect(registry.isRoutable("agent-1")).toBe(true);
47
+ });
48
+
49
+ it("should reject duplicate active agent ID (first-wins)", () => {
50
+ registry.register(makeAgent("agent-1"));
51
+ const ok = registry.register(makeAgent("agent-1"));
52
+ expect(ok).toBe(false);
53
+ });
54
+
55
+ it("should reject duplicate away agent ID", () => {
56
+ registry.register(makeAgent("agent-1"));
57
+ registry.disconnect("agent-1");
58
+ const ok = registry.register(makeAgent("agent-1"));
59
+ expect(ok).toBe(false);
60
+ });
61
+
62
+ it("should allow re-registering an expired agent ID", () => {
63
+ registry.register(makeAgent("agent-1"));
64
+ registry.disconnect("agent-1");
65
+ vi.advanceTimersByTime(6000); // past grace period
66
+ expect(registry.getStatus("agent-1")).toBe("expired");
67
+ const ok = registry.register(makeAgent("agent-1"));
68
+ expect(ok).toBe(true);
69
+ expect(registry.getStatus("agent-1")).toBe("active");
70
+ });
71
+
72
+ it("should store the agent in storage", () => {
73
+ registry.register(makeAgent("agent-1"));
74
+ const stored = storage.getAgent("agent-1");
75
+ expect(stored).toBeDefined();
76
+ expect(stored!.status).toBe("active");
77
+ });
78
+ });
79
+
80
+ describe("disconnect", () => {
81
+ it("should transition agent to away", () => {
82
+ registry.register(makeAgent("agent-1"));
83
+ const ok = registry.disconnect("agent-1");
84
+ expect(ok).toBe(true);
85
+ expect(registry.getStatus("agent-1")).toBe("away");
86
+ expect(registry.isRoutable("agent-1")).toBe(true); // Still routable during grace
87
+ });
88
+
89
+ it("should return false for non-active agent", () => {
90
+ expect(registry.disconnect("unknown")).toBe(false);
91
+ });
92
+
93
+ it("should expire after grace period", () => {
94
+ registry.register(makeAgent("agent-1"));
95
+ registry.disconnect("agent-1");
96
+ vi.advanceTimersByTime(5001);
97
+ expect(registry.getStatus("agent-1")).toBe("expired");
98
+ expect(registry.isRoutable("agent-1")).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe("reconnect", () => {
103
+ it("should restore away agent to active", () => {
104
+ registry.register(makeAgent("agent-1"));
105
+ registry.disconnect("agent-1");
106
+ expect(registry.getStatus("agent-1")).toBe("away");
107
+ const ok = registry.reconnect("agent-1");
108
+ expect(ok).toBe(true);
109
+ expect(registry.getStatus("agent-1")).toBe("active");
110
+ });
111
+
112
+ it("should cancel grace period timer", () => {
113
+ registry.register(makeAgent("agent-1"));
114
+ registry.disconnect("agent-1");
115
+ registry.reconnect("agent-1");
116
+ vi.advanceTimersByTime(6000); // Would have expired
117
+ expect(registry.getStatus("agent-1")).toBe("active");
118
+ });
119
+
120
+ it("should return false for non-away agent", () => {
121
+ registry.register(makeAgent("agent-1"));
122
+ expect(registry.reconnect("agent-1")).toBe(false); // Active, not away
123
+ });
124
+
125
+ it("should emit reconnected event", () => {
126
+ const spy = vi.fn();
127
+ events.on("registry.reconnected", spy);
128
+ registry.register(makeAgent("agent-1"));
129
+ registry.disconnect("agent-1");
130
+ registry.reconnect("agent-1");
131
+ expect(spy).toHaveBeenCalledWith("agent-1");
132
+ });
133
+ });
134
+
135
+ describe("expire", () => {
136
+ it("should transition to expired and update storage", () => {
137
+ registry.register(makeAgent("agent-1"));
138
+ registry.disconnect("agent-1");
139
+ vi.advanceTimersByTime(5001);
140
+ expect(registry.getStatus("agent-1")).toBe("expired");
141
+ const stored = storage.getAgent("agent-1");
142
+ expect(stored?.status).toBe("offline");
143
+ });
144
+
145
+ it("should auto-remove after retainExpiredMs", () => {
146
+ registry.register(makeAgent("agent-1"));
147
+ registry.disconnect("agent-1");
148
+ vi.advanceTimersByTime(5001); // grace → expired
149
+ vi.advanceTimersByTime(10001); // retain → removed
150
+ expect(registry.getStatus("agent-1")).toBe("unknown");
151
+ });
152
+ });
153
+
154
+ describe("listRoutable", () => {
155
+ it("should list active and away agents", () => {
156
+ registry.register(makeAgent("agent-1"));
157
+ registry.register(makeAgent("agent-2"));
158
+ registry.disconnect("agent-2");
159
+ const routable = registry.listRoutable();
160
+ expect(routable).toContain("agent-1");
161
+ expect(routable).toContain("agent-2");
162
+ });
163
+
164
+ it("should not list expired agents", () => {
165
+ registry.register(makeAgent("agent-1"));
166
+ registry.disconnect("agent-1");
167
+ vi.advanceTimersByTime(5001);
168
+ expect(registry.listRoutable()).not.toContain("agent-1");
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { SqliteStorage } from "../src/storage/sqlite.js";
3
+ import type { Agent, Message, Conversation, Turn } from "../src/types.js";
4
+
5
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
6
+ return {
7
+ agent_id: "agent-1",
8
+ scope: "default",
9
+ status: "active",
10
+ metadata: {},
11
+ registered_at: "2025-01-01T00:00:00Z",
12
+ last_active_at: "2025-01-01T00:00:00Z",
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function makeMessage(overrides: Partial<Message> = {}): Message {
18
+ return {
19
+ id: "msg-1",
20
+ scope: "default",
21
+ sender_id: "agent-1",
22
+ recipients: [{ agent_id: "agent-2", kind: "to" }],
23
+ content: { type: "text", text: "hello" },
24
+ importance: "normal",
25
+ metadata: {},
26
+ created_at: "2025-01-01T00:00:00Z",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe("SqliteStorage", () => {
32
+ let storage: SqliteStorage;
33
+
34
+ beforeEach(() => {
35
+ storage = new SqliteStorage({ path: ":memory:" });
36
+ });
37
+
38
+ afterEach(() => {
39
+ storage.close();
40
+ });
41
+
42
+ describe("Agents", () => {
43
+ it("should put and get an agent", () => {
44
+ const agent = makeAgent();
45
+ storage.putAgent(agent);
46
+ const result = storage.getAgent("agent-1");
47
+ expect(result).toBeDefined();
48
+ expect(result!.agent_id).toBe("agent-1");
49
+ expect(result!.status).toBe("active");
50
+ });
51
+
52
+ it("should list agents by scope", () => {
53
+ storage.putAgent(makeAgent({ agent_id: "a1", scope: "team-a" }));
54
+ storage.putAgent(makeAgent({ agent_id: "a2", scope: "team-b" }));
55
+ storage.putAgent(makeAgent({ agent_id: "a3", scope: "team-a" }));
56
+
57
+ expect(storage.listAgents("team-a")).toHaveLength(2);
58
+ expect(storage.listAgents("team-b")).toHaveLength(1);
59
+ expect(storage.listAgents()).toHaveLength(3);
60
+ });
61
+
62
+ it("should remove an agent", () => {
63
+ storage.putAgent(makeAgent());
64
+ expect(storage.removeAgent("agent-1")).toBe(true);
65
+ expect(storage.getAgent("agent-1")).toBeUndefined();
66
+ });
67
+
68
+ it("should update agent on re-put", () => {
69
+ storage.putAgent(makeAgent({ status: "active" }));
70
+ storage.putAgent(makeAgent({ status: "offline" }));
71
+ expect(storage.getAgent("agent-1")!.status).toBe("offline");
72
+ });
73
+ });
74
+
75
+ describe("Messages", () => {
76
+ it("should store and retrieve message with recipients", () => {
77
+ const msg = makeMessage();
78
+ storage.putMessage(msg);
79
+ const result = storage.getMessage("msg-1");
80
+ expect(result).toBeDefined();
81
+ expect(result!.sender_id).toBe("agent-1");
82
+ expect(result!.recipients).toHaveLength(1);
83
+ expect(result!.recipients[0].agent_id).toBe("agent-2");
84
+ expect(result!.content).toEqual({ type: "text", text: "hello" });
85
+ });
86
+
87
+ it("should get inbox for agent", () => {
88
+ storage.putMessage(
89
+ makeMessage({ id: "m1", recipients: [{ agent_id: "bob", kind: "to" }], created_at: "2025-01-01T00:00:01Z" })
90
+ );
91
+ storage.putMessage(
92
+ makeMessage({ id: "m2", recipients: [{ agent_id: "bob", kind: "to" }], created_at: "2025-01-01T00:00:02Z" })
93
+ );
94
+ storage.putMessage(
95
+ makeMessage({ id: "m3", recipients: [{ agent_id: "alice", kind: "to" }] })
96
+ );
97
+
98
+ const inbox = storage.getInbox("bob");
99
+ expect(inbox).toHaveLength(2);
100
+ });
101
+
102
+ it("should filter unread only", () => {
103
+ storage.putMessage(
104
+ makeMessage({
105
+ id: "m1",
106
+ recipients: [{ agent_id: "bob", kind: "to", read_at: "2025-01-01T00:00:05Z" }],
107
+ })
108
+ );
109
+ storage.putMessage(
110
+ makeMessage({
111
+ id: "m2",
112
+ recipients: [{ agent_id: "bob", kind: "to" }],
113
+ })
114
+ );
115
+
116
+ const unread = storage.getInbox("bob", { unreadOnly: true });
117
+ expect(unread).toHaveLength(1);
118
+ expect(unread[0].id).toBe("m2");
119
+ });
120
+
121
+ it("should get thread by tag", () => {
122
+ storage.putMessage(makeMessage({ id: "m1", thread_tag: "sprint-1", scope: "default" }));
123
+ storage.putMessage(makeMessage({ id: "m2", thread_tag: "sprint-1", scope: "default" }));
124
+ storage.putMessage(makeMessage({ id: "m3", thread_tag: "sprint-2", scope: "default" }));
125
+
126
+ const thread = storage.getThread({ threadTag: "sprint-1", scope: "default" });
127
+ expect(thread).toHaveLength(2);
128
+ });
129
+
130
+ it("should update message recipients on re-put", () => {
131
+ const msg = makeMessage({
132
+ recipients: [{ agent_id: "bob", kind: "to" }],
133
+ });
134
+ storage.putMessage(msg);
135
+
136
+ msg.recipients[0].read_at = "2025-01-01T01:00:00Z";
137
+ storage.putMessage(msg);
138
+
139
+ const result = storage.getMessage("msg-1")!;
140
+ expect(result.recipients[0].read_at).toBe("2025-01-01T01:00:00Z");
141
+ });
142
+ });
143
+
144
+ describe("Full-text search", () => {
145
+ it("should find messages by text content", () => {
146
+ storage.putMessage(
147
+ makeMessage({ id: "m1", content: { type: "text", text: "fix the auth bug" } })
148
+ );
149
+ storage.putMessage(
150
+ makeMessage({ id: "m2", content: { type: "text", text: "deploy the app" } })
151
+ );
152
+
153
+ const results = storage.searchMessages("auth");
154
+ expect(results).toHaveLength(1);
155
+ expect(results[0].id).toBe("m1");
156
+ });
157
+
158
+ it("should find messages by subject", () => {
159
+ storage.putMessage(
160
+ makeMessage({ id: "m1", subject: "auth issue", content: { type: "text", text: "details" } })
161
+ );
162
+ storage.putMessage(
163
+ makeMessage({ id: "m2", subject: "deploy plan", content: { type: "text", text: "steps" } })
164
+ );
165
+
166
+ const results = storage.searchMessages("auth");
167
+ expect(results).toHaveLength(1);
168
+ });
169
+
170
+ it("should filter search by scope", () => {
171
+ storage.putMessage(
172
+ makeMessage({ id: "m1", scope: "team-a", content: { type: "text", text: "auth fix" } })
173
+ );
174
+ storage.putMessage(
175
+ makeMessage({ id: "m2", scope: "team-b", content: { type: "text", text: "auth fix" } })
176
+ );
177
+
178
+ const results = storage.searchMessages("auth", "team-a");
179
+ expect(results).toHaveLength(1);
180
+ expect(results[0].scope).toBe("team-a");
181
+ });
182
+ });
183
+
184
+ describe("Conversations", () => {
185
+ it("should store and retrieve with participants", () => {
186
+ const conv: Conversation = {
187
+ id: "conv-1",
188
+ scope: "default",
189
+ subject: "Test",
190
+ status: "active",
191
+ participants: [
192
+ { agent_id: "alice", joined_at: "2025-01-01T00:00:00Z" },
193
+ { agent_id: "bob", joined_at: "2025-01-01T00:00:00Z" },
194
+ ],
195
+ metadata: {},
196
+ created_at: "2025-01-01T00:00:00Z",
197
+ updated_at: "2025-01-01T00:00:00Z",
198
+ };
199
+ storage.putConversation(conv);
200
+ const result = storage.getConversation("conv-1")!;
201
+ expect(result.subject).toBe("Test");
202
+ expect(result.participants).toHaveLength(2);
203
+ });
204
+ });
205
+
206
+ describe("Turns & Threads", () => {
207
+ it("should add and retrieve turns", () => {
208
+ const turn: Turn = {
209
+ id: "turn-1",
210
+ conversation_id: "conv-1",
211
+ participant_id: "alice",
212
+ content_type: "text",
213
+ content: { type: "text", text: "hello" },
214
+ created_at: "2025-01-01T00:00:00Z",
215
+ };
216
+ // Need conversation first for FK
217
+ storage.putConversation({
218
+ id: "conv-1", scope: "default", status: "active", participants: [],
219
+ metadata: {}, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z",
220
+ });
221
+ storage.addTurn(turn);
222
+ const turns = storage.getTurns("conv-1");
223
+ expect(turns).toHaveLength(1);
224
+ expect(turns[0].participant_id).toBe("alice");
225
+ });
226
+
227
+ it("should store and retrieve threads", () => {
228
+ storage.putConversation({
229
+ id: "conv-1", scope: "default", status: "active", participants: [],
230
+ metadata: {}, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z",
231
+ });
232
+ storage.putThread({
233
+ id: "thread-1",
234
+ conversation_id: "conv-1",
235
+ root_turn_id: "turn-1",
236
+ created_at: "2025-01-01T00:00:00Z",
237
+ });
238
+ const threads = storage.getThreadsByConversation("conv-1");
239
+ expect(threads).toHaveLength(1);
240
+ expect(threads[0].root_turn_id).toBe("turn-1");
241
+ });
242
+ });
243
+ });