pi-roundtable 0.1.0 → 0.2.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 (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -1,278 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { NO_ATTACHMENTS } from "../domain/attachment.ts";
3
- import type {
4
- PendingConfirmation,
5
- TurnResult,
6
- } from "../domain/conversation.ts";
7
- import { AgentRunError } from "../domain/errors.ts";
8
- import type { AgentTurnScope, TurnRequest } from "../domain/ports.ts";
9
- import { messages } from "../i18n/index.ts";
10
- import { silentLogger } from "../log.ts";
11
- import { ChannelQueue } from "../routing/channel-queue.ts";
12
- import { TEST_OWNER as OWNER, OWNER_SPEAKER } from "../testing/owner.ts";
13
- import { FakeThreadHost, fakeThreads } from "../testing/thread-host.ts";
14
- import type { AgentPost } from "./agent-ports.ts";
15
- import type { Agent, AgentGroup, AgentStore } from "./agent-store.ts";
16
- import { channelKey } from "./team-keys.ts";
17
- import { TeamTurns } from "./team-turns.ts";
18
-
19
- const agent = (name: string, channelId: string): Agent => ({
20
- name,
21
- displayName: name === "infra" ? "Infrastructure" : "Coordinator",
22
- prompt: "",
23
- avatarPrompt: "",
24
- channelId,
25
- status: "active",
26
- });
27
-
28
- const AGENTS = [agent("coordinator", "1000"), agent("infra", "2000")];
29
- const GROUP: AgentGroup = {
30
- name: "ops",
31
- displayName: "Ops",
32
- channelId: "3000",
33
- members: ["coordinator", "infra"],
34
- host: "coordinator",
35
- status: "active",
36
- };
37
-
38
- /** The store methods messages between agents read, over two agents and one group. */
39
- const store = {
40
- agent: (name: string) => AGENTS.find((a) => a.name === name),
41
- activeAgent: (name: string) => {
42
- const found = AGENTS.find((a) => a.name === name);
43
- if (!found) throw new Error(`no agent ${name}`);
44
- return found;
45
- },
46
- agentByChannel: (id: string) => AGENTS.find((a) => a.channelId === id),
47
- group: (name: string) => (name === GROUP.name ? GROUP : undefined),
48
- groupByChannel: (id: string) => (id === GROUP.channelId ? GROUP : undefined),
49
- } as unknown as AgentStore;
50
-
51
- const coordinator: AgentTurnScope = {
52
- name: "coordinator",
53
- session: channelKey("1000"),
54
- home: channelKey("1000"),
55
- };
56
-
57
- function setup(
58
- answer: (request: TurnRequest) => TurnResult,
59
- host = new FakeThreadHost(),
60
- withThreads = true,
61
- held?: PendingConfirmation,
62
- ) {
63
- const posts: { channelId: string; post: AgentPost }[] = [];
64
- const turns: TurnRequest[] = [];
65
- /** The replies the judge was asked to read as approvals. */
66
- const judged: string[] = [];
67
- const { threads } = fakeThreads(host);
68
- const queue = new ChannelQueue();
69
- const team = new TeamTurns({
70
- owner: OWNER,
71
- entryChannelId: "1000",
72
- store,
73
- channels: {
74
- post: async (channelId, post) => {
75
- posts.push({ channelId, post });
76
- },
77
- },
78
- studio: { url: () => "https://x/a.png" },
79
- runtime: () => ({
80
- runTurn: async (request) => {
81
- turns.push(request);
82
- return answer(request);
83
- },
84
- heldActions: async () => held,
85
- startFresh: async () => undefined,
86
- contextUsage: () => undefined,
87
- }),
88
- selection: () => ({ id: "agent", tools: [], groups: [] }),
89
- confirmations: {
90
- approves: async (_pending, reply) => {
91
- judged.push(reply);
92
- return true;
93
- },
94
- },
95
- scorer: { score: async () => undefined },
96
- queue,
97
- startTyping: () => () => undefined,
98
- showStop: () => () => undefined,
99
- logger: silentLogger(),
100
- changed: () => undefined,
101
- ...(withThreads ? { threads } : {}),
102
- });
103
- /** Posts in a channel, or in one of its threads, as `name: text`. */
104
- const texts = (channelId: string, threadId?: string) =>
105
- posts
106
- .filter((p) => p.channelId === channelId && p.post.threadId === threadId)
107
- .map((p) => `${p.post.name}: ${p.post.chunks.join("")}`);
108
- return { team, host, posts, turns, texts, judged };
109
- }
110
-
111
- /** Runs a coordinator turn, whose model sends the message, and waits for the chain to settle. */
112
- async function run(team: TeamTurns): Promise<void> {
113
- await team.answerBackground(channelKey("1000"), OWNER_SPEAKER, "go");
114
- for (let i = 0; i < 30; i++) await Bun.sleep(2);
115
- }
116
-
117
- describe("message_agent threads", () => {
118
- test("the exchange goes in a thread of the sender's channel, archived before the sender's follow-up", async () => {
119
- let archivedAtFollowUp: string[] | undefined;
120
- const host = new FakeThreadHost();
121
- const { team, texts, turns } = setup((request) => {
122
- if (request.agent?.name === "coordinator" && turns.length === 1)
123
- team.message(coordinator, "infra", "check the disk");
124
- if (request.agent?.name === "coordinator" && turns.length > 1)
125
- archivedAtFollowUp = [...host.closed];
126
- return {
127
- ok: true,
128
- text: request.agent?.name === "infra" ? "disk 80%" : "ok",
129
- };
130
- }, host);
131
- await run(team);
132
- expect(host.opened).toEqual([
133
- { parentId: "1000", name: "→ Infrastructure", id: "900" },
134
- ]);
135
- expect(host.lines.get("1000")).toBe(
136
- messages().threadStarted("→ Infrastructure", "<#900>"),
137
- );
138
- expect(texts("1000", "900")).toEqual([
139
- `Coordinator: ${messages().messageDelivered("Infrastructure", "check the disk")}`,
140
- `Infrastructure: ${messages().answerReturned("disk 80%")}`,
141
- ]);
142
- // The target's own channel is unchanged; the sender's channel holds only its own replies.
143
- expect(texts("2000")).toEqual([
144
- `Coordinator: ${messages().messageDelivered("Infrastructure", "check the disk")}`,
145
- "Infrastructure: disk 80%",
146
- ]);
147
- expect(texts("1000")).toEqual(["Coordinator: ok", "Coordinator: ok"]);
148
- expect(archivedAtFollowUp).toEqual(["900"]);
149
- const followUp = turns.at(-1);
150
- expect(followUp?.channel).toBe(channelKey("1000"));
151
- expect(followUp?.text).toContain("in the thread <#900>");
152
- expect(followUp?.text).toContain("disk 80%"); // Only the answer's follow-up is a report turn, which may ask the owner on cards.
153
- expect(turns.map((t) => [t.agent?.name, t.interactive])).toEqual([
154
- ["coordinator", undefined],
155
- ["infra", undefined],
156
- ["coordinator", true],
157
- ]);
158
- });
159
-
160
- test("a report turn in an agent's channel may ask on cards; a schedule's may not", async () => {
161
- const { team, turns } = setup(() => ({ ok: true, text: "ok" }));
162
- await team.answerBackground(channelKey("1000"), OWNER_SPEAKER, "scheduled");
163
- await team.answerBackground(
164
- channelKey("1000"),
165
- OWNER_SPEAKER,
166
- "report",
167
- true,
168
- );
169
- expect(turns.map((t) => t.interactive)).toEqual([undefined, true]);
170
- });
171
-
172
- test("a failed or stopped answer is reported in the thread, which is archived", async () => {
173
- const { team, host, turns } = setup((request) => {
174
- if (request.agent?.name === "coordinator" && turns.length === 1)
175
- team.message(coordinator, "infra", "x");
176
- return request.agent?.name === "infra"
177
- ? {
178
- ok: false,
179
- error: new AgentRunError("stopped by the owner"),
180
- stopped: true,
181
- }
182
- : { ok: true, text: "ok" };
183
- });
184
- await run(team);
185
- expect(host.textsIn("900")).toEqual([
186
- messages().agentNoReply("stopped by the owner"),
187
- ]);
188
- expect(host.closed).toEqual(["900"]);
189
- expect(turns.at(-1)?.text).toContain("could not answer");
190
- expect(turns.at(-1)?.text).toContain("<#900>");
191
- });
192
-
193
- test("without a thread the answer comes back in the sender's channel as before", async () => {
194
- const host = new FakeThreadHost();
195
- host.failOpen = true;
196
- const { team, texts, turns } = setup((request) => {
197
- if (request.agent?.name === "coordinator" && turns.length === 1)
198
- team.message(coordinator, "infra", "x");
199
- return { ok: true, text: `${request.agent?.name} ok` };
200
- }, host);
201
- await run(team);
202
- expect(texts("1000")).toContain(
203
- `Infrastructure: ${messages().answerReturned("infra ok")}`,
204
- );
205
- expect(turns.at(-1)?.text).not.toContain("thread");
206
- });
207
-
208
- test("a group round's dispatches open their threads in the group channel", () => {
209
- const { team } = setup(() => ({ ok: true, text: "" }), undefined, false);
210
- expect(team.turnChannel({ ...coordinator, group: "ops" })).toBe(
211
- channelKey("3000"),
212
- );
213
- expect(team.turnChannel(coordinator)).toBe(channelKey("1000"));
214
- });
215
- });
216
-
217
- describe("speakers", () => {
218
- test("a message chain keeps the speaker who started it through every hop", async () => {
219
- const admin = { id: "2", name: "Ada", tier: "admin" } as const;
220
- const { team, turns } = setup((request) => {
221
- if (request.agent?.name === "coordinator" && turns.length === 1)
222
- team.message(coordinator, "infra", "check the disk");
223
- return { ok: true, text: "ok" };
224
- });
225
- await team.answerBackground(channelKey("1000"), admin, "go");
226
- for (let i = 0; i < 30; i++) await Bun.sleep(2);
227
- expect(turns.map((t) => [t.agent?.name, t.speaker])).toEqual([
228
- ["coordinator", admin],
229
- ["infra", admin],
230
- ["coordinator", admin],
231
- ]);
232
- });
233
- });
234
-
235
- describe("approvals", () => {
236
- const shell: PendingConfirmation = {
237
- profile: "general",
238
- heldAt: new Date(),
239
- calls: [{ tool: "bash", input: "{}", action: "run a command" }],
240
- };
241
- const answer = () => ({ ok: true, text: "ok" }) as const;
242
- const reply = (
243
- team: TeamTurns,
244
- tier: "owner" | "admin" | "member",
245
- ): Promise<unknown> =>
246
- team.answerOwner(
247
- channelKey("1000"),
248
- { id: "2", name: "Ada", tier },
249
- "yes, run it",
250
- "yes, run it",
251
- NO_ATTACHMENTS,
252
- );
253
-
254
- test("a speaker whose tier holds the held tool approves it", async () => {
255
- const { team, turns, judged } = setup(answer, undefined, true, shell);
256
- await reply(team, "owner");
257
- expect(judged).toEqual(["yes, run it"]);
258
- expect(turns[0]?.confirmed).toBe(true);
259
- });
260
-
261
- test("a lower tier is not asked to approve, so the held action stays held", async () => {
262
- const { team, turns, judged } = setup(answer, undefined, true, shell);
263
- await reply(team, "admin");
264
- await reply(team, "member");
265
- expect(judged).toEqual([]);
266
- expect(turns.map((t) => t.confirmed)).toEqual([undefined, undefined]);
267
- });
268
-
269
- test("an admin approves what an admin may use", async () => {
270
- const held: PendingConfirmation = {
271
- ...shell,
272
- calls: [{ tool: "agent_create", input: "{}", action: "create an agent" }],
273
- };
274
- const { team, turns } = setup(answer, undefined, true, held);
275
- await reply(team, "admin");
276
- expect(turns[0]?.confirmed).toBe(true);
277
- });
278
- });
@@ -1,85 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { createCanvas, loadImage } from "canvas";
6
- import { NO_ATTACHMENTS } from "../domain/attachment.ts";
7
- import { readAttachment } from "../shared/attachment-reader.ts";
8
- import { safeFileName } from "./attachment-fetcher.ts";
9
- import { prepareImage } from "./image-prep.ts";
10
- import { withAttachmentsBlock } from "./prompt-block.ts";
11
-
12
- const dir = mkdtempSync(join(tmpdir(), "roundtable-read-"));
13
-
14
- describe("safeFileName", () => {
15
- test("keeps names readable and strips path parts", () => {
16
- expect(safeFileName("отчёт v2.pdf")).toBe("отчёт v2.pdf");
17
- expect(safeFileName("../../etc/passwd")).toBe("__.._etc_passwd");
18
- expect(safeFileName("")).toBe("file");
19
- });
20
- });
21
-
22
- describe("prepareImage", () => {
23
- test("an image over the side limit is downscaled to 2,000 px JPEG", async () => {
24
- const canvas = createCanvas(9000, 100);
25
- const path = join(dir, "wide.png");
26
- await Bun.write(path, canvas.toBuffer("image/png"));
27
- const image = await prepareImage({ path, contentType: "image/png" });
28
- expect(image.mimeType).toBe("image/jpeg");
29
- const decoded = await loadImage(Buffer.from(image.data, "base64"));
30
- expect(decoded.width).toBe(2000);
31
- });
32
- });
33
-
34
- describe("readAttachment", () => {
35
- test("reads text in pieces and refuses paths and binaries", async () => {
36
- await Bun.write(join(dir, "a.txt"), "x".repeat(70_000));
37
- const first = await readAttachment(dir, "a.txt");
38
- expect(first.text).toHaveLength(60_000);
39
- expect(first.total).toBe(70_000);
40
- expect((await readAttachment(dir, "a.txt", 60_000)).text).toHaveLength(
41
- 10_000,
42
- );
43
- await expect(readAttachment(dir, "../a.txt")).rejects.toThrow(
44
- "not an attachment name",
45
- );
46
- await Bun.write(join(dir, "b.bin"), new Uint8Array([0, 1, 2, 255]));
47
- await expect(readAttachment(dir, "b.bin")).rejects.toThrow(
48
- "not a text or PDF file",
49
- );
50
- await expect(readAttachment(dir, "none.txt")).rejects.toThrow(
51
- "no attachment named",
52
- );
53
- });
54
- });
55
-
56
- describe("withAttachmentsBlock", () => {
57
- test("adds nothing without attachments", () => {
58
- expect(withAttachmentsBlock("hi", NO_ATTACHMENTS)).toBe("hi");
59
- });
60
-
61
- test("lists files and failures", () => {
62
- const text = withAttachmentsBlock("hi", {
63
- files: [
64
- {
65
- name: "a.pdf",
66
- file: "m-0-a.pdf",
67
- path: "/x",
68
- contentType: "application/pdf",
69
- size: 2048,
70
- fromReference: false,
71
- },
72
- ],
73
- images: [],
74
- failures: [
75
- { name: "b.mov", reason: "larger than 25 MB", fromReference: true },
76
- ],
77
- });
78
- expect(text).toContain(
79
- '- m-0-a.pdf (original name "a.pdf", application/pdf, 2 KB)',
80
- );
81
- expect(text).toContain(
82
- '"b.mov" from the replied-to message could not be received: larger than 25 MB',
83
- );
84
- });
85
- });
@@ -1,45 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { readdirSync, readFileSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
4
-
5
- const ROOT = resolve(import.meta.dir, "../..");
6
- const CORE = join(ROOT, "src/core");
7
-
8
- function coreFiles(dir: string): string[] {
9
- return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
10
- const path = join(dir, entry.name);
11
- if (entry.isDirectory()) return coreFiles(path);
12
- return entry.name.endsWith(".ts") ? [path] : [];
13
- });
14
- }
15
-
16
- /** Relative imports of a file, as repository paths. */
17
- function localImports(file: string): string[] {
18
- const source = readFileSync(file, "utf8");
19
- return [...source.matchAll(/from\s+"(\.{1,2}\/[^"]+)"/g)].map((match) =>
20
- relative(ROOT, resolve(dirname(file), match[1] ?? "")),
21
- );
22
- }
23
-
24
- describe("core boundary", () => {
25
- test("the core imports only itself", () => {
26
- const outside = coreFiles(CORE).flatMap((file) =>
27
- localImports(file)
28
- .filter((path) => !path.startsWith("src/core/"))
29
- .map((path) => `${relative(ROOT, file)} -> ${path}`),
30
- );
31
- expect(outside).toEqual([]);
32
- });
33
-
34
- test("the core never imports a consumer's plugins", () => {
35
- const consumers = readdirSync(join(ROOT, "src"), { withFileTypes: true })
36
- .filter((entry) => entry.isDirectory() && entry.name !== "core")
37
- .map((entry) => `src/${entry.name}/`);
38
- const found = coreFiles(CORE).flatMap((file) =>
39
- localImports(file).filter((path) =>
40
- consumers.some((prefix) => path.startsWith(prefix)),
41
- ),
42
- );
43
- expect(found).toEqual([]);
44
- });
45
- });
@@ -1,127 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- ExtensionAPI,
4
- ExtensionFactory,
5
- } from "@earendil-works/pi-coding-agent";
6
- import {
7
- type AgentTurnScope,
8
- type ChannelKey,
9
- compileSessionPlan,
10
- type SessionContext,
11
- } from "../sessions.ts";
12
- import { OWNER_CHANNEL, setUpModules } from "../testing/modules.ts";
13
-
14
- const HOME: ChannelKey = "discord:scout";
15
- const GROUP: ChannelKey = "discord:war-room";
16
- const OUTSIDE: ChannelKey = "party:table-1";
17
-
18
- const scout: AgentTurnScope = { name: "scout", session: HOME, home: HOME };
19
- const seat: AgentTurnScope = {
20
- name: "scout",
21
- session: GROUP,
22
- home: HOME,
23
- group: "war-room",
24
- };
25
-
26
- function context(agent?: AgentTurnScope, home?: ChannelKey): SessionContext {
27
- const session: SessionContext = {
28
- kind: agent ? "agent" : "owner",
29
- homeChannel: home ?? agent?.home ?? OWNER_CHANNEL,
30
- turnChannel: agent?.session ?? home ?? OWNER_CHANNEL,
31
- compaction: { wrap: (compactor) => compactor },
32
- speaker: () => undefined,
33
- runTask: async () => "report",
34
- };
35
- if (agent) session.agent = agent;
36
- return session;
37
- }
38
-
39
- interface Registered {
40
- name: string;
41
- parameters: { properties: Record<string, unknown> };
42
- execute(
43
- id: string,
44
- params: unknown,
45
- ): Promise<{ content: { text: string }[]; isError?: boolean }>;
46
- }
47
-
48
- /** The tools one of the modules' extensions registers in a session. */
49
- async function registered(
50
- setup: Awaited<ReturnType<typeof setUpModules>>,
51
- session: SessionContext,
52
- extension: string,
53
- ): Promise<Registered[]> {
54
- const tool = setup.contribution.sessionTools?.find(
55
- (candidate) => candidate.name === extension,
56
- );
57
- const factory: ExtensionFactory | null | undefined = tool
58
- ?.snapshot()
59
- .factory(session);
60
- if (!factory) throw new Error(`no ${extension} extension`);
61
- const tools: Registered[] = [];
62
- await factory({
63
- registerTool: (definition: Registered) => tools.push(definition),
64
- on: () => undefined,
65
- } as unknown as ExtensionAPI);
66
- return tools;
67
- }
68
-
69
- describe("modulesPlugin", () => {
70
- test("adds the owner's tools in a fixed order, and the delegator to the shutdown drain", async () => {
71
- const setup = await setUpModules();
72
- const { sessionTools, services } = setup.contribution;
73
- expect(sessionTools?.map((tool) => tool.name)).toEqual([
74
- "owner-memory",
75
- "notify",
76
- "schedules",
77
- "delegate",
78
- "discord-admin",
79
- ]);
80
- expect(sessionTools?.every((tool) => tool.phase === "tools")).toBe(true);
81
- expect(services?.map((service) => service.name)).toEqual(["delegator"]);
82
- expect(compileSessionPlan(sessionTools ?? []).tools).toHaveLength(5);
83
- });
84
-
85
- test("provides the background turns and the delegator to the plugins after it", async () => {
86
- const { core } = await setUpModules();
87
- expect(core.background).toBeDefined();
88
- expect(core.delegator.runningChannels()).toEqual([]);
89
- });
90
-
91
- test("delegate_task reports home and opens its thread in the group", async () => {
92
- const setup = await setUpModules();
93
- const [delegate] = await registered(setup, context(seat), "delegate");
94
- await delegate?.execute("1", { title: "t", task: "look it up" });
95
- await setup.core.delegator.idle();
96
- expect(setup.record.reportChannels).toEqual([HOME]);
97
- expect(setup.record.threadOrigins).toEqual([GROUP]);
98
- expect(setup.record.ownerChannelAsked).toBe(0);
99
- });
100
-
101
- test("a conversation without a chat channel reports in the owner's messages", async () => {
102
- const setup = await setUpModules();
103
- const [delegate] = await registered(
104
- setup,
105
- context(undefined, OUTSIDE),
106
- "delegate",
107
- );
108
- await delegate?.execute("1", { title: "t", task: "look it up" });
109
- await setup.core.delegator.idle();
110
- expect(setup.record.reportChannels).toEqual([OWNER_CHANNEL]);
111
- expect(setup.record.ownerChannelAsked).toBe(1);
112
- });
113
-
114
- test("only agent sessions may read another agent's schedules", async () => {
115
- const setup = await setUpModules({ agentChannelOf: () => HOME });
116
- const list = async (session: SessionContext) =>
117
- (await registered(setup, session, "schedules")).find(
118
- (tool) => tool.name === "schedule_list",
119
- );
120
- expect(
121
- Object.keys((await list(context(scout)))?.parameters.properties ?? {}),
122
- ).toContain("agent");
123
- expect(
124
- Object.keys((await list(context()))?.parameters.properties ?? {}),
125
- ).not.toContain("agent");
126
- });
127
- });
@@ -1,135 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { ConfigError } from "../domain/errors.ts";
3
- import { type RoundtableConfig, resolveConfig } from "./config.ts";
4
-
5
- const minimal: RoundtableConfig = {
6
- owner: { id: "100000000000000001", name: "Ada" },
7
- discord: {
8
- token: "token",
9
- guild: "900000000000000001",
10
- entryChannel: "900000000000000002",
11
- },
12
- database: { url: "postgres://localhost/roundtable" },
13
- dataDir: "/data",
14
- model: "anthropic/claude-sonnet-5-5",
15
- http: { publicUrl: "https://bot.example.com" },
16
- };
17
-
18
- const refused = (input: unknown): string => {
19
- try {
20
- resolveConfig(input);
21
- } catch (error) {
22
- if (error instanceof ConfigError) return error.message;
23
- throw error;
24
- }
25
- return "";
26
- };
27
-
28
- describe("resolveConfig", () => {
29
- test("fills every default from a minimal configuration", () => {
30
- const config = resolveConfig(minimal);
31
- expect(config.name).toBe("Roundtable");
32
- expect(config.owner).toEqual({
33
- id: "100000000000000001",
34
- name: "Ada",
35
- pronouns: { subject: "they", object: "them", possessive: "their" },
36
- });
37
- expect(config.discord.rootCommand).toBe("roundtable");
38
- expect(config.agentDir).toBe("/data/pi");
39
- expect(config.model).toEqual({
40
- provider: "anthropic",
41
- id: "claude-sonnet-5-5",
42
- });
43
- expect(config.thinking).toBe("medium");
44
- expect(config.judge).toEqual({ model: config.model, threshold: 0.6 });
45
- expect(config.delegation).toEqual({
46
- model: config.model,
47
- thinking: "medium",
48
- });
49
- expect(config.locale).toBe("en");
50
- expect(config.timeZone).toBe("UTC");
51
- expect(config.http.port).toBe(3000);
52
- expect(config.agents).toEqual([]);
53
- expect(config.plugins).toEqual([]);
54
- });
55
-
56
- test("the root command follows the assistant's name unless one is given", () => {
57
- expect(
58
- resolveConfig({ ...minimal, name: "Robin Hood" }).discord.rootCommand,
59
- ).toBe("robin-hood");
60
- expect(
61
- resolveConfig({
62
- ...minimal,
63
- discord: { ...minimal.discord, rootCommand: "rh" },
64
- }).discord.rootCommand,
65
- ).toBe("rh");
66
- });
67
-
68
- test("pronouns become the words prompts use", () => {
69
- const owner = { ...minimal.owner, pronouns: "she" as const };
70
- expect(resolveConfig({ ...minimal, owner }).owner.pronouns).toEqual({
71
- subject: "she",
72
- object: "her",
73
- possessive: "her",
74
- });
75
- });
76
-
77
- test("an unknown key names the nearest known key, at any depth", () => {
78
- expect(refused({ ...minimal, discrod: {} })).toStartWith(
79
- 'config discrod: unknown key. Did you mean "discord"? The keys here are name, owner, discord,',
80
- );
81
- expect(
82
- refused({ ...minimal, discord: { ...minimal.discord, tokn: "x" } }),
83
- ).toContain('config discord.tokn: unknown key. Did you mean "token"?');
84
- });
85
-
86
- test("a missing required key names it and the fix", () => {
87
- const { model: _model, ...without } = minimal;
88
- expect(refused(without)).toBe(
89
- "config model: required, expected a non-empty string. Add it to roundtable.config.ts.",
90
- );
91
- expect(refused({ ...minimal, owner: { name: "Ada" } })).toBe(
92
- "config owner.id: required, expected a non-empty string. Add it to roundtable.config.ts.",
93
- );
94
- });
95
-
96
- test("a wrong value says what was expected and what it got", () => {
97
- expect(refused({ ...minimal, thinking: "loud" })).toBe(
98
- 'config thinking: expected one of off, minimal, low, medium, high, xhigh, got "loud". Fix the value in roundtable.config.ts.',
99
- );
100
- expect(
101
- refused({ ...minimal, http: { publicUrl: "https://x", port: 70000 } }),
102
- ).toContain(
103
- "config http.port: expected an integer from 1 to 65535, got 70000.",
104
- );
105
- expect(refused({ ...minimal, model: "sonnet" })).toBe(
106
- 'config model: expected <provider>/<id>, got "sonnet". Write it like anthropic/claude-sonnet-5-5.',
107
- );
108
- expect(refused({ ...minimal, locale: "fr" })).toContain(
109
- "config locale: expected a locale, en or zh-TW",
110
- );
111
- expect(refused({ ...minimal, toolTiers: { shell: "root" } })).toContain(
112
- "config toolTiers.shell: expected one of owner, admin, member",
113
- );
114
- expect(refused({ ...minimal, plugins: [{ name: "x" }] })).toContain(
115
- "config plugins[0]: expected a plugin, an object with a name and a setup function",
116
- );
117
- expect(refused("nope")).toContain("expected an object");
118
- });
119
-
120
- test("agents are seeds with their four texts", () => {
121
- const seed = {
122
- name: "coordinator",
123
- displayName: "Coordinator",
124
- prompt: "You coordinate.",
125
- avatarPrompt: "A calm coordinator.",
126
- channelId: "900000000000000002",
127
- };
128
- expect(resolveConfig({ ...minimal, agents: [seed] }).agents).toEqual([
129
- seed,
130
- ]);
131
- expect(refused({ ...minimal, agents: [{ name: "x" }] })).toContain(
132
- "config agents[0].displayName: required",
133
- );
134
- });
135
- });