pi-roundtable 0.1.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.
Files changed (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,536 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { InteractionContextType } from "discord.js";
6
+ import type { ChannelClaim, ConversationPort } from "./contract/channels.ts";
7
+ import { NotLinkedError, PluginError } from "./errors.ts";
8
+ import type { HoldRule } from "./holds.ts";
9
+ import { Roundtable, type RoundtableOptions } from "./host.ts";
10
+ import { silentLogger } from "./log.ts";
11
+ import type { LinkedSessions, RoundtablePlugin, Service } from "./plugin.ts";
12
+ import type { SessionTool } from "./sessions.ts";
13
+
14
+ /** A service that records its start and stop in `log`. */
15
+ function recorded(
16
+ name: string,
17
+ log: string[],
18
+ extra: Partial<Service> = {},
19
+ ): Service {
20
+ return {
21
+ name,
22
+ start: () => {
23
+ log.push(`start ${name}`);
24
+ },
25
+ stop: () => {
26
+ log.push(`stop ${name}`);
27
+ },
28
+ ...extra,
29
+ };
30
+ }
31
+
32
+ function plugin(name: string, services: Service[]): RoundtablePlugin {
33
+ return { name, setup: () => ({ services }) };
34
+ }
35
+
36
+ function host(
37
+ plugins: RoundtablePlugin[],
38
+ options: Partial<RoundtableOptions> = {},
39
+ ): { roundtable: Roundtable; exits: number[] } {
40
+ const exits: number[] = [];
41
+ const roundtable = new Roundtable(
42
+ {
43
+ logger: silentLogger(),
44
+ drain: { intervalMs: 1, limitMs: 50 },
45
+ exit: (code) => exits.push(code),
46
+ ...options,
47
+ },
48
+ plugins,
49
+ );
50
+ return { roundtable, exits };
51
+ }
52
+
53
+ describe("Roundtable", () => {
54
+ test("starts services in registration order and stops them in reverse", async () => {
55
+ const log: string[] = [];
56
+ const { roundtable, exits } = host([
57
+ plugin("a", [recorded("a1", log), recorded("a2", log)]),
58
+ plugin("b", [recorded("b1", log)]),
59
+ ]);
60
+ await roundtable.run();
61
+ await roundtable.shutdown("SIGTERM");
62
+ expect(log).toEqual([
63
+ "start a1",
64
+ "start a2",
65
+ "start b1",
66
+ "stop b1",
67
+ "stop a2",
68
+ "stop a1",
69
+ ]);
70
+ expect(exits).toEqual([0]);
71
+ });
72
+
73
+ test("waits for busy work before stopping anything", async () => {
74
+ const log: string[] = [];
75
+ let busy = ["discord:1"];
76
+ const { roundtable } = host([
77
+ plugin("a", [recorded("a1", log, { busy: () => busy })]),
78
+ ]);
79
+ await roundtable.run();
80
+ const done = roundtable.shutdown("SIGTERM");
81
+ await Bun.sleep(10);
82
+ expect(log).toEqual(["start a1"]);
83
+ busy = [];
84
+ await done;
85
+ expect(log).toEqual(["start a1", "stop a1"]);
86
+ });
87
+
88
+ test("hands aborted work over once the drain limit runs out", async () => {
89
+ const aborted: string[][] = [];
90
+ const { roundtable, exits } = host(
91
+ [plugin("a", [{ name: "a1", busy: () => ["discord:1", "discord:2"] }])],
92
+ {
93
+ aborted: async (left) => {
94
+ aborted.push(left);
95
+ },
96
+ },
97
+ );
98
+ await roundtable.run();
99
+ await roundtable.shutdown("SIGTERM");
100
+ expect(aborted).toEqual([["discord:1", "discord:2"]]);
101
+ expect(exits).toEqual([0]);
102
+ });
103
+
104
+ test("keeps stopping the rest when one service fails to stop", async () => {
105
+ const log: string[] = [];
106
+ const { roundtable } = host([
107
+ plugin("a", [
108
+ recorded("a1", log),
109
+ {
110
+ name: "broken",
111
+ stop: () => {
112
+ throw new Error("socket already closed");
113
+ },
114
+ },
115
+ ]),
116
+ ]);
117
+ await roundtable.run();
118
+ await roundtable.shutdown("SIGTERM");
119
+ expect(log).toEqual(["start a1", "stop a1"]);
120
+ });
121
+
122
+ test("tells every plugin how the agent server started, even after a handler fails", async () => {
123
+ const outcomes: string[] = [];
124
+ const run = async (agentServer: () => Promise<void>) => {
125
+ const { roundtable } = host([
126
+ { name: "server", agentServer, setup: () => ({}) },
127
+ {
128
+ name: "broken",
129
+ setup: () => ({
130
+ events: {
131
+ agentServer: () => {
132
+ throw new Error("channel missing");
133
+ },
134
+ },
135
+ }),
136
+ },
137
+ {
138
+ name: "listener",
139
+ setup: () => ({
140
+ events: {
141
+ agentServer: (outcome) => {
142
+ outcomes.push(outcome);
143
+ },
144
+ },
145
+ }),
146
+ },
147
+ ]);
148
+ await roundtable.run();
149
+ await Bun.sleep(5);
150
+ };
151
+ await run(async () => {});
152
+ await run(async () => {
153
+ throw new Error("Missing Access");
154
+ });
155
+ expect(outcomes).toEqual(["ready", "failed"]);
156
+ });
157
+
158
+ test("tells the plugins what the drain gave up on before any service stops", async () => {
159
+ const log: string[] = [];
160
+ const { roundtable } = host(
161
+ [
162
+ plugin("a", [recorded("a1", log, { busy: () => ["discord:1"] })]),
163
+ {
164
+ name: "watcher",
165
+ setup: () => ({
166
+ events: {
167
+ shutdown: (left) => {
168
+ log.push(`drained ${left.join(",")}`);
169
+ },
170
+ },
171
+ }),
172
+ },
173
+ ],
174
+ { aborted: async () => {} },
175
+ );
176
+ await roundtable.run();
177
+ await roundtable.shutdown("SIGTERM");
178
+ expect(log).toEqual(["start a1", "drained discord:1", "stop a1"]);
179
+ });
180
+
181
+ test("refuses two plugins that both start the agent server", async () => {
182
+ const { roundtable } = host([
183
+ { name: "a", agentServer: async () => {}, setup: () => ({}) },
184
+ { name: "b", agentServer: async () => {}, setup: () => ({}) },
185
+ ]);
186
+ await expect(roundtable.run()).rejects.toThrow(
187
+ "plugins a and b both start the agent server. Keep one.",
188
+ );
189
+ });
190
+
191
+ test("a plugin that only hooks the lifecycle adds something; one with no hook and no part adds nothing", async () => {
192
+ const { roundtable } = host([
193
+ { name: "hook", preflight: async () => {}, setup: () => ({}) },
194
+ ]);
195
+ await roundtable.run();
196
+ await roundtable.shutdown("SIGTERM");
197
+ });
198
+
199
+ test("refuses a plugin that adds nothing", async () => {
200
+ const { roundtable } = host([plugin("empty", [])]);
201
+ const error = await roundtable.run().catch((e: unknown) => e);
202
+ expect(error).toBeInstanceOf(PluginError);
203
+ });
204
+
205
+ test("refuses two services with one name", async () => {
206
+ const log: string[] = [];
207
+ const { roundtable } = host([
208
+ plugin("a", [recorded("dashboard", log)]),
209
+ plugin("b", [recorded("dashboard", log)]),
210
+ ]);
211
+ const error = await roundtable.run().catch((e: unknown) => e);
212
+ expect(error).toBeInstanceOf(PluginError);
213
+ expect(log).toEqual([]);
214
+ });
215
+
216
+ test("composes commands before any service starts, and serves HTTP only while every service runs", async () => {
217
+ const log: string[] = [];
218
+ const socketPath = join(mkdtempSync(join(tmpdir(), "host-")), "web.sock");
219
+ const get = async () => {
220
+ try {
221
+ const response = await fetch("http://localhost/ping", {
222
+ unix: socketPath,
223
+ });
224
+ return await response.text();
225
+ } catch {
226
+ return "closed";
227
+ }
228
+ };
229
+ const { roundtable } = host(
230
+ [
231
+ {
232
+ name: "a",
233
+ useCommands: (composed) =>
234
+ log.push(`commands ${composed.commands.map((c) => c.name)}`),
235
+ setup: () => ({
236
+ services: [
237
+ {
238
+ name: "a1",
239
+ start: async () => {
240
+ log.push(`start a1: ${await get()}`);
241
+ },
242
+ stop: async () => {
243
+ log.push(`stop a1: ${await get()}`);
244
+ },
245
+ },
246
+ ],
247
+ interactions: [
248
+ {
249
+ module: { commands: () => [], handle: async () => false },
250
+ rootOptions: [{ type: 1, name: "help", description: "help" }],
251
+ },
252
+ ],
253
+ http: [
254
+ {
255
+ name: "ping",
256
+ listener: "web",
257
+ path: { exact: "/ping" },
258
+ handle: () => new Response("pong"),
259
+ },
260
+ ],
261
+ }),
262
+ },
263
+ ],
264
+ {
265
+ commands: {
266
+ root: {
267
+ name: "roundtable",
268
+ description: "control",
269
+ contexts: [InteractionContextType.Guild],
270
+ },
271
+ },
272
+ listeners: [{ id: "web", socketPath }],
273
+ },
274
+ );
275
+ await roundtable.run();
276
+ log.push(`running: ${await get()}`);
277
+ await roundtable.shutdown("SIGTERM");
278
+ expect(log).toEqual([
279
+ "commands roundtable",
280
+ "start a1: closed",
281
+ "running: pong",
282
+ "stop a1: closed",
283
+ ]);
284
+ });
285
+
286
+ test("links every plugin's hold rules, packages, and session tools once all are set up", async () => {
287
+ const hold = (name: string, tool: string): HoldRule => ({
288
+ name,
289
+ describe: (called) => (called === tool ? name : undefined),
290
+ });
291
+ const tool = (name: string, phase: SessionTool["phase"]): SessionTool => ({
292
+ name,
293
+ phase,
294
+ ...(phase === "compaction" ? { engine: name } : {}),
295
+ snapshot: () => ({ revision: 1, factory: () => null }),
296
+ });
297
+ const seen: string[] = [];
298
+ let sessions: (() => LinkedSessions) | undefined;
299
+ const { roundtable } = host([
300
+ {
301
+ name: "a",
302
+ preflight: async () => void seen.push("preflight"),
303
+ setup: (context) => {
304
+ sessions = context.sessions;
305
+ try {
306
+ context.sessions();
307
+ } catch (error) {
308
+ if (error instanceof NotLinkedError) seen.push("not linked");
309
+ }
310
+ return {
311
+ holdRules: [hold("discord", "x"), hold("shell", "y")],
312
+ piPackages: ["pi-web-access", "pi-self-compact"],
313
+ sessionTools: [tool("mcp", "mcp"), tool("memory", "tools")],
314
+ };
315
+ },
316
+ },
317
+ {
318
+ name: "b",
319
+ setup: () => ({
320
+ holdRules: [hold("google", "x")],
321
+ piPackages: ["pi-self-compact", "pi-otel-tracing"],
322
+ sessionTools: [tool("summarizer", "compaction")],
323
+ }),
324
+ },
325
+ ]);
326
+ await roundtable.run();
327
+ const linked = sessions?.();
328
+ expect(seen).toEqual(["not linked", "preflight"]);
329
+ // The first rule to describe a call holds it.
330
+ expect(linked?.holds("x", {}, {})).toBe("discord");
331
+ expect(linked?.holds("y", {}, {})).toBe("shell");
332
+ expect(linked?.holds("z", {}, {})).toBeUndefined();
333
+ expect(linked?.piPackages).toEqual([
334
+ "pi-web-access",
335
+ "pi-self-compact",
336
+ "pi-otel-tracing",
337
+ ]);
338
+ expect(linked?.plan.tools.map((t) => t.name)).toEqual(["memory"]);
339
+ expect(linked?.plan.compaction?.name).toBe("summarizer");
340
+ expect(linked?.plan.mcp.map((t) => t.name)).toEqual(["mcp"]);
341
+ });
342
+
343
+ test("routes conversations to contributed claims once linked, not during setup", async () => {
344
+ const log: string[] = [];
345
+ const claim = (name: string, priority: number): ChannelClaim => ({
346
+ name,
347
+ priority,
348
+ owns: () => true,
349
+ admit: (m) => ({
350
+ kind: "turn",
351
+ run: async () => void log.push(`${name} ${m.text}`),
352
+ failure: "failed",
353
+ }),
354
+ startFresh: async () => name,
355
+ });
356
+ let conversations: ConversationPort | undefined;
357
+ const { roundtable } = host([
358
+ {
359
+ name: "owner",
360
+ stopTurn: (channel) => channel === "discord:1",
361
+ setup: (context) => {
362
+ conversations = context.conversations;
363
+ expect(() => context.conversations.stop("discord:1")).toThrow(
364
+ NotLinkedError,
365
+ );
366
+ return { channels: [claim("owner", 0)] };
367
+ },
368
+ },
369
+ { name: "party", setup: () => ({ channels: [claim("party", 20)] }) },
370
+ ]);
371
+ await roundtable.run();
372
+ await conversations?.handle({
373
+ channel: "discord:1",
374
+ messageId: "m1",
375
+ authorId: "owner",
376
+ authorName: "Riley",
377
+ authorIsBot: false,
378
+ isDirect: true,
379
+ mentionsBot: false,
380
+ repliesToBot: false,
381
+ text: "hi",
382
+ attachments: [],
383
+ });
384
+ expect(log).toEqual(["party hi"]);
385
+ expect(conversations?.stop("discord:1")).toBe(true);
386
+ });
387
+
388
+ test("gives the dashboard every plugin's lines in order, once linked", async () => {
389
+ let read: (() => readonly string[]) | undefined;
390
+ let early: unknown;
391
+ const { roundtable } = host([
392
+ {
393
+ name: "web",
394
+ setup: ({ dashboard }) => {
395
+ try {
396
+ dashboard();
397
+ } catch (error) {
398
+ early = error;
399
+ }
400
+ read = dashboard;
401
+ return { dashboard: ["web link"] };
402
+ },
403
+ },
404
+ { name: "metrics", setup: () => ({ dashboard: ["metrics link"] }) },
405
+ ]);
406
+ await roundtable.run();
407
+ expect(early).toBeInstanceOf(NotLinkedError);
408
+ expect(read?.()).toEqual(["web link", "metrics link"]);
409
+ });
410
+
411
+ test("refuses clashing session parts before any service starts", async () => {
412
+ const log: string[] = [];
413
+ const rule: HoldRule = { name: "shell", describe: () => undefined };
414
+ const { roundtable } = host([
415
+ {
416
+ name: "a",
417
+ setup: () => ({ services: [recorded("a1", log)], holdRules: [rule] }),
418
+ },
419
+ { name: "b", setup: () => ({ holdRules: [rule] }) },
420
+ ]);
421
+ const error = await roundtable.run().catch((e: unknown) => e);
422
+ expect(error).toBeInstanceOf(PluginError);
423
+ expect(log).toEqual([]);
424
+ });
425
+
426
+ describe("a failed startup reaches neither Discord nor a listener", () => {
427
+ /** A host whose one service stands for Discord, with a real web listener. */
428
+ const failing = (
429
+ fail: Pick<RoundtablePlugin, "setup"> | undefined,
430
+ preflight?: () => Promise<void>,
431
+ ) => {
432
+ const log: string[] = [];
433
+ const socketPath = join(mkdtempSync(join(tmpdir(), "host-")), "web.sock");
434
+ const plugins: RoundtablePlugin[] = [
435
+ {
436
+ name: "discord",
437
+ useCommands: () => void log.push("commands"),
438
+ agentServer: async () => void log.push("agent server"),
439
+ ...(preflight ? { preflight } : {}),
440
+ setup: () => ({
441
+ services: [recorded("surface", log)],
442
+ interactions: [
443
+ {
444
+ module: { commands: () => [], handle: async () => false },
445
+ rootOptions: [{ type: 1, name: "help", description: "help" }],
446
+ },
447
+ ],
448
+ }),
449
+ },
450
+ ...(fail ? [{ name: "web", ...fail }] : []),
451
+ ];
452
+ const { roundtable } = host(plugins, {
453
+ commands: {
454
+ root: {
455
+ name: "roundtable",
456
+ description: "control",
457
+ contexts: [InteractionContextType.Guild],
458
+ },
459
+ },
460
+ listeners: [{ id: "web", socketPath }],
461
+ });
462
+ return { roundtable, log, socketPath };
463
+ };
464
+
465
+ test("when a setup fails, as a web bundle that does not build", async () => {
466
+ const { roundtable, log, socketPath } = failing({
467
+ setup: async () => {
468
+ throw new AggregateError([], "Bundle failed");
469
+ },
470
+ });
471
+ expect(
472
+ await roundtable.run().then(
473
+ () => "ran",
474
+ () => "failed",
475
+ ),
476
+ ).toBe("failed");
477
+ expect(log).toEqual([]);
478
+ expect(existsSync(socketPath)).toBe(false);
479
+ });
480
+
481
+ test("when the preflight fails", async () => {
482
+ const { roundtable, log, socketPath } = failing(undefined, async () => {
483
+ throw new Error("profile tools are not registered: memory_add");
484
+ });
485
+ expect(
486
+ await roundtable.run().then(
487
+ () => "ran",
488
+ () => "failed",
489
+ ),
490
+ ).toBe("failed");
491
+ expect(log).toEqual([]);
492
+ expect(existsSync(socketPath)).toBe(false);
493
+ });
494
+
495
+ test("but a good one does, commands first and the listener last", async () => {
496
+ const { roundtable, log, socketPath } = failing(undefined, async () => {
497
+ log.push("preflight");
498
+ });
499
+ await roundtable.run();
500
+ await Bun.sleep(5);
501
+ expect(log).toEqual([
502
+ "preflight",
503
+ "commands",
504
+ "start surface",
505
+ "agent server",
506
+ ]);
507
+ expect(existsSync(socketPath)).toBe(true);
508
+ await roundtable.shutdown("SIGTERM");
509
+ });
510
+ });
511
+
512
+ test("refuses overlapping routes before any service starts", async () => {
513
+ const log: string[] = [];
514
+ const route = (name: string) => ({
515
+ name,
516
+ listener: "web",
517
+ path: { prefix: "/app/" },
518
+ handle: () => new Response(name),
519
+ });
520
+ const { roundtable } = host(
521
+ [
522
+ {
523
+ name: "a",
524
+ setup: () => ({
525
+ services: [recorded("a1", log)],
526
+ http: [route("app"), route("app-again")],
527
+ }),
528
+ },
529
+ ],
530
+ { listeners: [{ id: "web", socketPath: "/nonexistent/web.sock" }] },
531
+ );
532
+ const error = await roundtable.run().catch((e: unknown) => e);
533
+ expect(error).toBeInstanceOf(PluginError);
534
+ expect(log).toEqual([]);
535
+ });
536
+ });